Detection rules › Panther
Panther rules: aws
A CloudTrail Was Created or Updated
#A CloudTrail Trail was created, updated, or enabled.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of CloudTrail changes
CLOUDTRAIL_CREATE_UPDATE = {
"CreateTrail",
"UpdateTrail",
"StartLogging",
}
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") in CLOUDTRAIL_CREATE_UPDATE
def title(event):
return f"CloudTrail [{event.deep_get('requestParameters', 'name')}] was created/updated"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_created.py
RuleID: "AWS.CloudTrail.Created"
DisplayName: "A CloudTrail Was Created or Updated"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Discovery:Cloud Service Dashboard
Reports:
CIS:
- 3.5
MITRE ATT&CK:
- TA0007:T1538
Stratus Red Team:
- aws.defense-evasion.cloudtrail-stop
Severity: Info
Description: >
A CloudTrail Trail was created, updated, or enabled.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-cloudtrail-modified
Reference: https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-create-and-update-a-trail.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofCreateTrail,UpdateTrail,StartLogging
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
name | requestParameters.name |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-cloudtrail-modified
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "CreateTrail",
"eventSource": "cloudtrail.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"name": "arn:aws:cloudtrail:us-west-2:123456789012:trail/example-trail"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "Tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
Account Security Configuration Changed
#An account wide security configuration was changed.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- ASL AWS Defense Evasion Delete Cloudtrail (Splunk)
- ASL AWS Defense Evasion Impair Security Services (Splunk)
- ASL AWS Defense Evasion Stop Logging Cloudtrail (Splunk)
- AWS Config Service Disabled (Panther)
- AWS Lateral Movement from Kubernetes SA via AssumeRoleWithWebIdentity (Elastic)
- AWS VPC Flow Logs Deleted (Sigma)
- AWS VPC Flow Logs Removed (Panther)
- AWSCloudTrail - AWS GuardDuty detector disabled or suspended (Kusto)
Detection logic
import json
from fnmatch import fnmatch
from unittest.mock import MagicMock
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
SECURITY_CONFIG_ACTIONS = {
"DeleteAccountPublicAccessBlock",
"DeleteDeliveryChannel",
"DeleteDetector",
"DeleteFlowLogs",
"DeleteRule",
"DeleteTrail",
"DisableEbsEncryptionByDefault",
"DisableRule",
"StopConfigurationRecorder",
"StopLogging",
}
ALLOW_LIST = [
# Add expected events and users here to suppress alerts
# {"userName": "ExampleUser", "eventName": "DeleteRule"},
]
def rule(event):
global ALLOW_LIST # pylint: disable=global-statement
if isinstance(ALLOW_LIST, MagicMock):
ALLOW_LIST = json.loads(ALLOW_LIST()) # pylint: disable=not-callable
if not aws_cloudtrail_success(event):
return False
for entry in ALLOW_LIST:
if fnmatch(
event.deep_get(
"userIdentity",
"sessionContext",
"sessionIssuer",
"userName",
default="",
),
entry["userName"],
):
if fnmatch(event.get("eventName"), entry["eventName"]):
return False
if event.get("eventName") == "UpdateDetector":
return not event.deep_get("requestParameters", "enable", default=True)
return event.get("eventName") in SECURITY_CONFIG_ACTIONS
def title(event):
return f"Sensitive AWS API call {event.get('eventName')} made by {event.udm('actor_user')}"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_security_configuration_change.py
RuleID: "AWS.CloudTrail.SecurityConfigurationChange"
DisplayName: "Account Security Configuration Changed"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Defense Evasion:Impair Defenses
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0005:T1562
Stratus Red Team:
- aws.defense-evasion.cloudtrail-delete
- aws.defense-evasion.cloudtrail-stop
- aws.defense-evasion.vpc-remove-flow-logs
Description: An account wide security configuration was changed.
Runbook: >
Verify that this change was planned. If not, revert the change and update the access control policies to ensure this doesn't happen again.
Reference: https://docs.aws.amazon.com/prescriptive-guidance/latest/aws-startup-security-baseline/controls-acct.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyany of:
all of:
eventNameisUpdateDetectorrequestParameters.enableis empty
all of:
eventNameis notUpdateDetectoreventNameis one ofDeleteAccountPublicAccessBlock,DeleteDeliveryChannel,DeleteDetector,DeleteFlowLogs,DeleteRule
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"UpdateDetector" |
eventName | in |
| field:"aws::eventName" kind:in |
eventName | ne |
| field:"aws::eventName" kind:ne value:"UpdateDetector" |
requestParameters.enable | is_null | field:"requestParameters.enable" kind:is_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
Verify that this change was planned. If not, revert the change and update the access control policies to ensure this doesn't happen again.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1111",
"eventName": "DeleteTrail",
"eventSource": "cloudtrail.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1111",
"requestParameters": {
"name": "example-trail"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla/2.0 (compatible; NEWT ActiveX; Win32)",
"userIdentity": {
"accessKeyId": "1111",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/example-role/example-user",
"invokedBy": "cloudformation.amazonaws.com",
"principalId": "1111",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/example-role",
"principalId": "1111",
"type": "Role",
"userName": "example-role"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
Amazon Machine Image (AMI) Modified to Allow Public Access
#An Amazon Machine Image (AMI) was modified to allow it to be launched by anyone. Any sensitive configuration or application data stored in the AMI's block devices is at risk.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_detection_helpers.caching import check_account_age
def rule(event):
# Only check successful ModiyImageAttribute events
if not aws_cloudtrail_success(event) or event.get("eventName") != "ModifyImageAttribute":
return False
added_perms = event.deep_get(
"requestParameters", "launchPermission", "add", "items", default=[{}]
)
for item in added_perms:
if item.get("group") == "all":
return True
if check_account_age(
item.get("userId", "") + "-" + event.udm("user_account_id", default="")
): # checking if the account is new
return True
return False
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ami_modified_for_public_access.py
RuleID: "AWS.CloudTrail.AMIModifiedForPublicAccess"
DisplayName: "Amazon Machine Image (AMI) Modified to Allow Public Access"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Exfiltration:Transfer Data to Cloud Account
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0010:T1537
Description: >
An Amazon Machine Image (AMI) was modified to allow it to be launched by anyone.
Any sensitive configuration or application data stored in the AMI's block devices is at risk.
Runbook: |
Determine if the AMI is intended to be publicly accessible.
If not, first modify the AMI to not be publicly accessible then change any sensitive data stored
in the block devices associated to the AMI (as they may be compromised).
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sharingamis-intro.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisModifyImageAttribute
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | ne | ModifyImageAttribute | excludes:eventName field:"eventName" value:"ModifyImageAttribute" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
errorMessage | is_null | field:"aws::errorMessage" kind:is_null | |
eventName | eq |
| field:"aws::eventName" kind:eq value:"ModifyImageAttribute" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Determine if the AMI is intended to be publicly accessible.
If not, first modify the AMI to not be publicly accessible then change any sensitive data stored
in the block devices associated to the AMI (as they may be compromised).
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1111",
"eventName": "ModifyImageAttribute",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1111",
"requestParameters": {
"attributeType": "launchPermission",
"imageId": "ami-1111",
"launchPermission": {
"add": {
"items": [
{
"group": "all"
}
]
}
}
},
"responseElements": {
"_return": true
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla/2.0 (compatible; NEWT ActiveX; Win32)",
"userIdentity": {
"accessKeyId": "1111",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/example-role/example-user",
"principalId": "1111",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/example-role",
"principalId": "1111",
"type": "Role",
"userName": "example-role"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Access Key Rotation
#This policy validates that AWS IAM account access keys are rotated every 90 days. Rotating access keys will reduce the window of opportunity for an access key that is associated with a compromised or terminated account to be used.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Detection logic
import datetime
from panther_base_helpers import resolve_timestamp_string
TIMEOUT_DAYS = datetime.timedelta(days=90)
def aged_out(timestamp):
if not timestamp:
return False
datetime_ts = resolve_timestamp_string(timestamp)
if not datetime_ts:
return False
return (datetime.datetime.now() - datetime_ts) > TIMEOUT_DAYS
def policy(resource):
# If a user is less than 4 hours old, it may not have a credential report generated yet.
# It will be re-scanned periodically until a credential report is found, at which point this
# policy will be properly evaluated.
report = resource.get("CredentialReport")
if not report:
return True
if report.get("AccessKey1Active"):
if aged_out(report.get("AccessKey1LastRotated")):
return False
if report.get("AccessKey2Active"):
if aged_out(report.get("AccessKey2LastRotated")):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_access_key_rotation.py
PolicyID: "AWS.AccessKey.Rotation"
DisplayName: "AWS Access Key Rotation"
Enabled: true
ResourceTypes:
- AWS.IAM.RootUser
- AWS.IAM.User
Tags:
- AWS
- Identity & Access Management
- Credential Access:Unsecured Credentials
Reports:
CIS:
- 1.4
MITRE ATT&CK:
- TA0006:T1552
Severity: Medium
Description: >
This policy validates that AWS IAM account access keys are rotated every 90 days.
Rotating access keys will reduce the window of opportunity for an access key that
is associated with a compromised or terminated account to be used.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-access-keys-rotated-every-90-days
Reference: >
https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
Stages and Predicates
Flags AWS.IAM.RootUser, AWS.IAM.User resources when the condition below holds.
Condition
CredentialReportis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
CredentialReport | is_null | excludes:CredentialReport |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
CredentialReport | is_not_null | field:"CredentialReport" kind:is_not_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-access-keys-rotated-every-90-days
AWS Access Keys At Account Creation
#This policy validates that AWS IAM user accounts do not have access keys that were created during account creation. This results in excess keys being generated, and unnecessary management work in auditing and rotating these keys.
Detection logic
from datetime import timedelta
from panther_base_helpers import deep_get, resolve_timestamp_string
AWS_TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
DEFAULT_TIME = "0001-01-01T00:00:00Z"
MAX_SECONDS_TO_AUTOGEN_KEY = timedelta(seconds=7)
def policy(resource):
# If a user is less than 4 hours old, it may not have a credential report generated yet.
# It will be re-scanned periodically until a credential report is found, at which point this
# policy will be properly evaluated.
if not resource.get("CredentialReport"):
return True
key_rot = deep_get(resource, "CredentialReport", "AccessKey1LastRotated")
if key_rot == DEFAULT_TIME:
return True
create = resource.get("TimeCreated", "")
key_rot_date = resolve_timestamp_string(key_rot)
create_date = resolve_timestamp_string(create)
if not key_rot_date or not create_date:
return True
return (key_rot_date - create_date) >= MAX_SECONDS_TO_AUTOGEN_KEY
Rule specification
AnalysisType: policy
Filename: aws_access_keys_at_account_creation.py
DisplayName: "AWS Access Keys At Account Creation"
PolicyID: "AWS.AccessKeys.AccountCreation"
Enabled: true
ResourceTypes:
- AWS.IAM.User
- AWS.IAM.RootUser
Tags:
- AWS
- Identity & Access Management
Reports:
CIS:
- 1.21
Severity: Low
Description: >
This policy validates that AWS IAM user accounts do not have access keys that were
created during account creation. This results in excess keys being generated,
and unnecessary management work in auditing and rotating these keys.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-access-keys-are-not-created-at-account-creation
Reference: >
https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
Stages and Predicates
Flags AWS.IAM.User, AWS.IAM.RootUser resources when all of the conditions below hold.
Condition
CredentialReportis presentCredentialReport.AccessKey1LastRotatedis not0001-01-01T00:00:00Z
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
CredentialReport | is_null | excludes:CredentialReport | |
CredentialReport.AccessKey1LastRotated | eq | 0001-01-01T00:00:00Z | excludes:CredentialReport.AccessKey1LastRotated field:"CredentialReport.AccessKey1LastRotated" value:"0001-01-01T00:00:00Z" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
CredentialReport | is_not_null | field:"CredentialReport" kind:is_not_null | |
CredentialReport.AccessKey1LastRotated | ne |
| field:"CredentialReport.AccessKey1LastRotated" kind:ne value:"0001-01-01T00:00:00Z" |
Response runbook
AWS ACM Certificate Expiration
#When a certificate is 60 days away from expiration, ACM automatically attempts to renew it every hour.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Resource Development |
Detection logic
import datetime
from panther_base_helpers import resolve_timestamp_string
AWS_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
EXPIRATION_BUFFER = datetime.timedelta(days=60)
def policy(resource):
if not resource.get("NotAfter"):
return False
timestamp = resolve_timestamp_string(resource.get("NotAfter"))
if not timestamp:
return True
time_to_expiration = timestamp - datetime.datetime.now()
return time_to_expiration >= EXPIRATION_BUFFER
Rule specification
AnalysisType: policy
Filename: aws_acm_certificate_expiration.py
PolicyID: "AWS.ACM.Certificate.Expiration"
DisplayName: "AWS ACM Certificate Expiration"
Enabled: true
ResourceTypes:
- AWS.ACM.Certificate
Reports:
MITRE ATT&CK:
- TA0042:T1588
Tags:
- AWS
- Availability
- Resource Development:Obtain Capabilities
Severity: Medium
Description: >
When a certificate is 60 days away from expiration, ACM automatically attempts to renew it every hour.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-acm-certificate-is-not-expired
Reference: https://docs.aws.amazon.com/acm/latest/userguide/troubleshooting-renewal.html
Stages and Predicates
Flags AWS.ACM.Certificate resources when the condition below holds.
Condition
NotAfteris empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
NotAfter | is_not_null | excludes:NotAfter |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
NotAfter | is_null | field:"NotAfter" kind:is_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-acm-certificate-is-not-expired
AWS ACM Certificate Status
#This policy checks if an ACM certificate renewal is pending or has failed and is in use by any other resources within the account.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Resource Development |
Detection logic
def policy(resource):
if not bool(resource["InUseBy"]):
return True
return resource["Status"] == "ISSUED"
Rule specification
AnalysisType: policy
Filename: aws_acm_certificate_valid.py
PolicyID: "AWS.ACM.Certificate.Valid"
DisplayName: "AWS ACM Certificate Status"
Enabled: true
ResourceTypes:
- AWS.ACM.Certificate
Reports:
MITRE ATT&CK:
- TA0042:T1588
Tags:
- AWS
- Operations
- Panther
- Resource Development:Obtain Capabilities
Severity: High
Description: >
This policy checks if an ACM certificate renewal is pending or has failed and is in use
by any other resources within the account.
Runbook: >
From the ACM panel in the AWS console, check and resolve the certificate status.
Reference: https://docs.aws.amazon.com/acm/latest/userguide/check-certificate-renewal-status.html
Stages and Predicates
Flags AWS.ACM.Certificate resources when all of the conditions below hold.
Condition
InUseByis presentStatusis notISSUED
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
InUseBy | is_null | excludes:InUseBy | |
Status | eq | ISSUED | excludes:Status field:"Status" value:"ISSUED" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
InUseBy | is_not_null | field:"InUseBy" kind:is_not_null | |
Status | ne |
| field:"Status" kind:ne value:"ISSUED" |
Response runbook
From the ACM panel in the AWS console, check and resolve the certificate status.
AWS ACM Secure Algorithms
#This policy validates that all ACM certificates are using secure key and signature algorithms.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
# Specify what key and signature algorithms meet your organizations level of security here.
# Included is the AWS ACM default when creating new certificates as an example.
SECURE_KEY_ALGORITHMS = {
"RSA-2048", # AWS ACM default
}
SECURE_SIGNATURE_ALGORITHMS = {
"SHA256WITHRSA", # AWS ACM default
}
def policy(resource):
return (
resource["KeyAlgorithm"] in SECURE_KEY_ALGORITHMS
and resource["SignatureAlgorithm"] in SECURE_SIGNATURE_ALGORITHMS
)
Rule specification
AnalysisType: policy
Filename: aws_acm_certificate_has_secure_algorithms.py
PolicyID: "AWS.ACM.HasSecureAlgorithms"
DisplayName: "AWS ACM Secure Algorithms"
Enabled: false
ResourceTypes:
- AWS.ACM.Certificate
Tags:
- AWS
- Configuration Required
- PCI
- Defense Evasion:Impair Defenses
Reports:
PCI:
- 2.2.3
MITRE ATT&CK:
- TA0005:T1562
Severity: High
Description: >
This policy validates that all ACM certificates are using secure key and signature algorithms.
Runbook: >
Delete the insecure certificate, and re-create with appropriately secure key and signature algorithms.
Reference: https://docs.aws.amazon.com/acm/latest/userguide/acm-certificate.html#algorithms
Stages and Predicates
Flags AWS.ACM.Certificate resources when any of the conditions below holds.
Condition
any of:
KeyAlgorithmis not one ofRSA-2048SignatureAlgorithmis not one ofSHA256WITHRSA
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
KeyAlgorithm | eq | RSA-2048 | excludes:KeyAlgorithm field:"KeyAlgorithm" value:"RSA-2048" |
SignatureAlgorithm | eq | SHA256WITHRSA | excludes:SignatureAlgorithm field:"SignatureAlgorithm" value:"SHA256WITHRSA" |
Response runbook
Delete the insecure certificate, and re-create with appropriately secure key and signature algorithms.
AWS Administrative IAM User Created
#Identifies when an Administrative IAM user is creates. This could indicate a potential security breach.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Rule specification
AnalysisType: correlation_rule
RuleID: "AWS.Administrative.IAM.User.Created.Group"
DisplayName: "AWS Administrative IAM User Created"
Enabled: false
Severity: Info
Tags:
- Beta
Description: Identifies when an Administrative IAM user is creates. This could indicate a potential security breach.
Reference: https://stratus-red-team.cloud/attack-techniques/AWS/aws.persistence.iam-create-admin-user/
Reports:
MITRE ATT&CK:
- TA0006:T1078
Detection:
- Group:
- ID: CreateUser
RuleID: AWS.IAM.CreateUser
- ID: AttachAdminUserPolicy
RuleID: AWS.IAM.AttachAdminUserPolicy
MatchCriteria:
field_name:
- GroupID: CreateUser
Match: p_alert_context.request_username
- GroupID: AttachAdminUserPolicy
Match: p_alert_context.request_username
LookbackWindowMinutes: 1800
Schedule:
RateMinutes: 1440
TimeoutMinutes: 5
Stages and Predicates
Fires when the steps below all occur within 30h, correlated by p_alert_context.request_username. Each step needs one match unless a higher minimum is shown.
Stage 1: step CreateUser
References detection IAM User Created.
Stage 2: step AttachAdminUserPolicy
References detection IAM User Policy Attached with Administrator Access.
AWS AMI Sharing
#This policy ensures that AMIs you have created are not configured to allow public access, which could result in accidental data loss. AMI's that you use but do not own are not evaluated by this policy.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Detection logic
# Add owners as desired
APPROVED_OWNERS = [
"amazon",
"microsoft",
]
def policy(resource):
# These are trusted public snapshot distributors, allow
if resource["ImageOwnerAlias"] in APPROVED_OWNERS:
return True
# Ignore AMIs that are not owned by the scanned account
if resource["AccountId"] != resource["OwnerId"]:
return True
return not resource["Public"]
Rule specification
AnalysisType: policy
Filename: aws_ami_private.py
PolicyID: "AWS.AMI.Private"
DisplayName: "AWS AMI Sharing"
Enabled: true
ResourceTypes:
- AWS.EC2.AMI
Tags:
- AWS
- Panther
- Panther Enterprise
- Exfiltration:Transfer Data to Cloud Account
Reports:
MITRE ATT&CK:
- TA0010:T1537
Severity: High
Description: >
This policy ensures that AMIs you have created are not configured to allow public access, which could result in accidental data loss. AMI's that you use but do not own are not evaluated by this policy.
Runbook: >
Immediately remove public access from the AMI until you can determine whether that setting is intentional
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sharing-amis.html
Stages and Predicates
Flags AWS.EC2.AMI resources when all of the conditions below hold.
Condition
ImageOwnerAliasis not one ofamazon,microsoftAccountIdequals fieldOwnerIdPublicis present
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
AccountId | cross_field_compare | OwnerId | excludes:AccountId field:"AccountId" value:"OwnerId" |
ImageOwnerAlias | in | amazon, microsoft | excludes:ImageOwnerAlias field:"ImageOwnerAlias" value:"amazon" field:"ImageOwnerAlias" value:"microsoft" |
Public | is_null | excludes:Public |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Public | is_not_null | field:"Public" kind:is_not_null |
Response runbook
Immediately remove public access from the AMI until you can determine whether that setting is intentional
AWS Application Load Balancer Web ACL
#This policy validates that all application load balancers have an associated Web ACl to enforce protections against various web attacks.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
from panther_base_helpers import deep_get
# MAPPINGS is a dictionary where the Key is an application load balancer ARN, and the
# Value is a WAF web ACL ID. For each Load Balancer ARN present in MAPPINGS,
# this rule verifies that the load balancer has the associated Web ACL
MAPPINGS = {
"TEST_LOAD_BALANCER_ARN": "TEST_WAF_WEB_ACL_ID",
}
def policy(resource):
# Check if a Web ACL is required for this load balancer
if resource["LoadBalancerArn"] not in MAPPINGS:
return True
# Check if a Web ACL exists for this load balancer
if resource["WebAcl"] is None:
return False
# Check that the correct Web ACL is assigned for this load balancer
return deep_get(resource, "WebAcl", "WebACLId") == MAPPINGS[resource["LoadBalancerArn"]]
Rule specification
AnalysisType: policy
Filename: aws_application_load_balancer_web_acl.py
PolicyID: "AWS.ApplicationLoadBalancer.WebACL"
DisplayName: "AWS Application Load Balancer Web ACL"
Enabled: false
ResourceTypes:
- AWS.ELBV2.ApplicationLoadBalancer
Tags:
- AWS
- Configuration Required
- Security Control
- Initial Access:Exploit Public-Facing Application
Reports:
MITRE ATT&CK:
- TA0001:T1190
Severity: High
Description: >
This policy validates that all application load balancers have an associated Web ACl to enforce protections against various web attacks.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-application-load-balancer-has-web-acl
Reference: https://aws.amazon.com/blogs/aws/aws-web-application-firewall-waf-for-application-load-balancers/
Stages and Predicates
Flags AWS.ELBV2.ApplicationLoadBalancer resources when all of the conditions below hold.
Condition
LoadBalancerArnis one ofTEST_LOAD_BALANCER_ARNWebAclis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
LoadBalancerArn | eq | TEST_LOAD_BALANCER_ARN | excludes:LoadBalancerArn field:"LoadBalancerArn" value:"TEST_LOAD_BALANCER_ARN" |
WebAcl | is_not_null | excludes:WebAcl |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
LoadBalancerArn | in |
| field:"LoadBalancerArn" kind:in value:"TEST_LOAD_BALANCER_ARN" |
WebAcl | is_null | field:"WebAcl" kind:is_null |
Response runbook
AWS Authentication From CrowdStrike Unmanaged Device
#Detects AWS Logins from IP addresses not found in CrowdStrike's AIP list. May indicate unmanaged device being used, or faulty CrowdStrike Sensor.
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Authentication from CrowdStrike Unmanaged Device (Panther)
- AWS Authentication from CrowdStrike Unmanaged Device (crowdstrike_fdrevent table) (Panther)
- AWS EC2 Instance Console Login via Assumed Role (Elastic)
- AWS IAM User Console Login from Multiple Geolocations (Elastic)
- AWS IAM User Console Login Without MFA (Elastic)
- AWS Management Console Brute Force of Root User Identity (Elastic)
- AWS Management Console Root Login (Elastic)
- AWS Sign-In Console Login with Federated User (Elastic)
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(_):
return True
def title(event):
return (
f"AWS [{event.get('eventName')}] for "
f"[{event.deep_get('userIdentity', 'arn', default = '<arn_not_found>')}]"
" from unmanaged IP Address."
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: scheduled_rule
Description: Detects AWS Logins from IP addresses not found in CrowdStrike's AIP list. May indicate unmanaged device being used, or faulty CrowdStrike Sensor.
DisplayName: "AWS Authentication From CrowdStrike Unmanaged Device"
Enabled: false
Filename: aws_authentication_from_crowdstrike_unmanaged_device.py
Reference: https://www.crowdstrike.com/wp-content/uploads/2023/05/crowdstrike-falcon-device-control-data-sheet.pdf
Severity: Medium
DedupPeriodMinutes: 60
RuleID: "AWS.Authentication.From.CrowdStrike.Unmanaged.Device"
Threshold: 1
ScheduledQueries:
- AWS Authentication from CrowdStrike Unmanaged Device
Tags:
- Multi-Table Query
Stages and Predicates
Rule logic
This rule alerts on rows returned by its scheduled query AWS Authentication from CrowdStrike Unmanaged Device; its Python module (Detection logic above) shapes the alert rather than filtering.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"LoginTo": "https://console.aws.amazon.com/console/home",
"MFAIdentifier": "arn:aws:iam::12345:mfa/homer_simpson",
"MFAUsed": "Yes",
"MobileVersion": "No"
},
"awsRegion": "us-east-2",
"eventCategory": "Management",
"eventID": "12345",
"eventName": "ConsoleLogin",
"eventSource": "signin.amazonaws.com",
"eventTime": "2023-01-10 20:10:41",
"eventType": "AwsConsoleSignIn",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "12345",
"responseElements": {
"ConsoleLogin": "Success"
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"clientProvidedHostHeader": "us-east-2.signin.aws.amazon.com",
"tlsVersion": "TLSv1.2"
},
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
"userIdentity": {
"accountId": "12345",
"arn": "arn:aws:iam::12345:user/homer_simpson",
"principalId": "ABCDEF",
"type": "IAMUser",
"userName": "homer_simpson"
}
}
AWS Authentication from CrowdStrike Unmanaged Device
#Detects AWS Authentication events with IP Addresses not found in CrowdStrike's AIP List
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Authentication From CrowdStrike Unmanaged Device (Panther)
- AWS Authentication from CrowdStrike Unmanaged Device (crowdstrike_fdrevent table) (Panther)
- AWS EC2 Instance Console Login via Assumed Role (Elastic)
- AWS IAM User Console Login from Multiple Geolocations (Elastic)
- AWS IAM User Console Login Without MFA (Elastic)
- AWS Management Console Brute Force of Root User Identity (Elastic)
- AWS Management Console Root Login (Elastic)
- AWS Sign-In Console Login with Federated User (Elastic)
Rule specification
AnalysisType: scheduled_query
Description: Detects AWS Authentication events with IP Addresses not found in CrowdStrike's AIP List
Enabled: false
SnowflakeQuery: |
SELECT *
FROM panther_logs.public.aws_cloudtrail
WHERE p_occurs_since('1 hour')
AND eventName IN ('ConsoleLogin', 'SignIn', 'GetSessionToken')
AND eventSource IN ('sts.amazonaws.com', 'signin.amazonaws.com')
AND sourceIPAddress NOT IN
(
SELECT DISTINCT aip
FROM panther_logs.public.crowdstrike_aidmaster
WHERE p_occurs_since('3 days')
)
DatabricksQuery: |
SELECT *
FROM panther_logs.aws_cloudtrail
WHERE p_occurs_since('1 hour')
AND eventName IN ('ConsoleLogin', 'SignIn', 'GetSessionToken')
AND eventSource IN ('sts.amazonaws.com', 'signin.amazonaws.com')
AND sourceIPAddress NOT IN
(
SELECT DISTINCT aip
FROM panther_logs.crowdstrike_aidmaster
WHERE p_occurs_since('3 days')
)
QueryName: "AWS Authentication from CrowdStrike Unmanaged Device"
Schedule:
RateMinutes: 60
TimeoutMinutes: 3
Tags:
- Multi-Table Query
Stages and Predicates
Stage 1: source
Stage 2: filter
eventNameis one ofConsoleLogin,SignIn,GetSessionTokeneventSourceis one ofsts.amazonaws.com,signin.amazonaws.comsourceIPAddressis not in the results of a subquery onpanther_logs.public.crowdstrike_aidmaster
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | in |
| field:"aws::eventSource" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
* |
AWS Authentication from CrowdStrike Unmanaged Device (crowdstrike_fdrevent table)
#Detects AWS Authentication events with IP Addresses not found in CrowdStrike's AIP List
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Authentication From CrowdStrike Unmanaged Device (Panther)
- AWS Authentication from CrowdStrike Unmanaged Device (Panther)
- AWS EC2 Instance Console Login via Assumed Role (Elastic)
- AWS IAM User Console Login from Multiple Geolocations (Elastic)
- AWS IAM User Console Login Without MFA (Elastic)
- AWS Management Console Brute Force of Root User Identity (Elastic)
- AWS Management Console Root Login (Elastic)
- AWS Sign-In Console Login with Federated User (Elastic)
Rule specification
# This file is the part of the Crowdstrike FDREvent migration, and it's the equivalent of
# https://github.com/panther-labs/panther-analysis/blob/b61db1ecf3967c5f6a44c1782f8891fd5f54384d/queries/aws_queries/AWS_Authentication_from_CrowdStrike_Unmanaged_Device.yml
#
AnalysisType: scheduled_query
Description: Detects AWS Authentication events with IP Addresses not found in CrowdStrike's AIP List
Enabled: false
SnowflakeQuery: |
SELECT *
FROM panther_logs.public.aws_cloudtrail
WHERE p_occurs_since('1 days')
AND eventName IN ('ConsoleLogin', 'SignIn', 'GetSessionToken')
AND eventSource IN ('sts.amazonaws.com', 'signin.amazonaws.com')
AND sourceIPAddress NOT IN
(
SELECT DISTINCT aip
FROM panther_logs.public.crowdstrike_fdrevent
WHERE p_occurs_since('3 days') AND panther_logs.public.crowdstrike_fdrevent.fdr_event_type = 'aid_master'
)
DatabricksQuery: |
SELECT *
FROM panther_logs.aws_cloudtrail
WHERE p_occurs_since('1 days')
AND eventName IN ('ConsoleLogin', 'SignIn', 'GetSessionToken')
AND eventSource IN ('sts.amazonaws.com', 'signin.amazonaws.com')
AND sourceIPAddress NOT IN
(
SELECT DISTINCT aip
FROM panther_logs.crowdstrike_fdrevent
WHERE p_occurs_since('3 days') AND panther_logs.crowdstrike_fdrevent.fdr_event_type = 'aid_master'
)
QueryName: "AWS Authentication from CrowdStrike Unmanaged Device (crowdstrike_fdrevent table)"
Schedule:
RateMinutes: 1440
TimeoutMinutes: 3
Tags:
- Multi-Table Query
Stages and Predicates
Stage 1: source
Stage 2: filter
eventNameis one ofConsoleLogin,SignIn,GetSessionTokeneventSourceis one ofsts.amazonaws.com,signin.amazonaws.comsourceIPAddressis not in the results of a subquery onpanther_logs.public.crowdstrike_fdrevent
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | in |
| field:"aws::eventSource" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
* |
AWS Backdoor Administrative IAM Role Created
#Identifies when CreateRole and AttachAdminRolePolicy CloudTrail events occur in a short period of time. This sequence could indicate a potential security breach.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Rule specification
AnalysisType: correlation_rule
RuleID: "AWS.Backdoor.Administrative.IAM.Role.Created.Group"
DisplayName: "AWS Backdoor Administrative IAM Role Created"
Enabled: false
Severity: High
Description: Identifies when CreateRole and AttachAdminRolePolicy CloudTrail events occur in a short period of time. This sequence could indicate a potential security breach.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
Reports:
MITRE ATT&CK:
- TA0007:T1078
Detection:
- Group:
- ID: CreateRole
RuleID: AWS.IAM.CreateRole
- ID: AttachAdminRolePolicy
RuleID: AWS.IAM.AttachAdminRolePolicy
MatchCriteria:
field_name:
- GroupID: CreateRole
Match: p_alert_context.request_rolename
- GroupID: AttachAdminRolePolicy
Match: p_alert_context.request_rolename
LookbackWindowMinutes: 1800
Schedule:
RateMinutes: 1440
TimeoutMinutes: 5
Stages and Predicates
Fires when the steps below all occur within 30h, correlated by p_alert_context.request_rolename. Each step needs one match unless a higher minimum is shown.
Stage 1: step CreateRole
References detection IAM Role Created.
Stage 2: step AttachAdminRolePolicy
References detection IAM Administrator Role Policy Attached.
AWS Bedrock Guardrail Updated or Deleted
#An Amazon Bedrock Guardrail was updated or deleted. Amazon Bedrock Guardrails are used to implement application-specific safeguards based on your use cases and responsible AI policies. Updating or deleting a guardrail can have security implications to your AI workloads.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
GUARDRAIL_EVENTS = {"DeleteGuardrail", "UpdateGuardrail"}
def rule(event):
if (
event.get("eventSource") == "bedrock.amazonaws.com"
and event.get("eventName") in GUARDRAIL_EVENTS
and aws_cloudtrail_success(event)
):
return True
return False
def title(event):
user = event.udm("actor_user")
guardrail = event.deep_get("requestParameters", "guardrailIdentifier")
action = event.get("eventName").replace("Guardrail", "").lower()
return f"User [{user}] {action}d Bedrock guardrail [{guardrail}]"
def severity(event):
if event.get("eventName") == "UpdateGuardrail":
return "LOW"
return "DEFAULT"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_bedrock_guardrail_update_delete.py
RuleID: "AWS.Bedrock.GuardrailUpdateDelete"
DisplayName: "AWS Bedrock Guardrail Updated or Deleted"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Bedrock
- Generative AI Guardrails
- AML.T0054
- LLM Jailbreak
- "Impair Defenses: Disable or Modify Tools"
- Defense Evasion
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0005:T1562.001 # Impair Defenses: Disable or Modify Tools
Description: >
An Amazon Bedrock Guardrail was updated or deleted.
Amazon Bedrock Guardrails are used to implement application-specific safeguards based on your use cases and responsible AI policies.
Updating or deleting a guardrail can have security implications to your AI workloads.
Runbook: |
Review the guardrail update or deletion to ensure that it was authorized and that it does not introduce security risks to your AI workloads.
If the guardrail update or deletion was unauthorized, investigate the incident and take appropriate action.
https://atlas.mitre.org/mitigations/AML.M0020
Reference: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_DeleteGuardrail.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisbedrock.amazonaws.comeventNameis one ofDeleteGuardrail,UpdateGuardrailerrorCodeis emptyerrorMessageis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"bedrock.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
guardrailIdentifier | requestParameters.guardrailIdentifier |
Response runbook
Review the guardrail update or deletion to ensure that it was authorized and that it does not introduce security risks to your AI workloads.
If the guardrail update or deletion was unauthorized, investigate the incident and take appropriate action.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "4d482238-d0c5-4337-800f-d1ed79957fd4",
"eventName": "UpdateGuardrail",
"eventSource": "bedrock.amazonaws.com",
"eventTime": "2025-01-21 17:39:10.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.09",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123123123123",
"requestID": "4ebcfaab-52e6-4027-9307-dbfe671b1cdb",
"requestParameters": {
"guardrailIdentifier": "cmy5azq5koeo",
"name": "HIDDEN_DUE_TO_SECURITY_REASONS"
},
"responseElements": {
"guardrailArn": "arn:aws:bedrock:us-west-2:123123123123:guardrail/cmy5azq5koeo",
"guardrailId": "cmy5azq5koeo",
"updatedAt": "2025-01-21T17:39:10.379877250Z",
"version": "DRAFT"
},
"sessionCredentialFromConsole": true,
"sourceIPAddress": "123.123.123.123",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "bedrock.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"userIdentity": {
"accessKeyId": "ASIAQWERQWERQWERQWER",
"accountId": "123123123123",
"arn": "arn:aws:sts::123123123123:assumed-role/DevAdmin/dr.evil",
"principalId": "AROAQWERQWERQWERQWER:dr.evil",
"sessionContext": {
"attributes": {
"creationDate": "2025-01-21T16:08:03Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "123123123123",
"arn": "arn:aws:iam::123123123123:role/aws-reserved/sso.amazonaws.com/us-west-2/DevAdmin",
"principalId": "AROAQWERQWERQWERQWER",
"type": "Role",
"userName": "DevAdmin"
}
},
"type": "AssumedRole"
}
}
AWS Bedrock Model Invocation Abnormal Token Usage
#Monitors for potential misuse or abuse of AWS Bedrock AI models by detecting abnormal token usage patterns and alerts when the total token usage exceeds the appropriate threshold for each different type of model.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
def rule(event):
# Only process InvokeModel and Converse operations
if event.get("operation") not in ["InvokeModel", "Converse"]:
return False
# retrieve the necessary values from the logs
token_usage = event.deep_get("output", "outputBodyJson", "usage", "totalTokens", default=0)
model_id = event.get("modelId", default="")
# Get the appropriate threshold for each model
if "haiku" in model_id:
threshold = 3000
elif "sonnet" in model_id:
threshold = 4000
elif "opus" in model_id:
threshold = 5000
else:
threshold = 4000 # default threshold
# Check for abnormal token usage
if token_usage > threshold:
return True
# Flag unusual token patterns (high usage with no actual output)
output_tokens = event.deep_get("output", "outputBodyJson", "usage", "outputTokens", default=0)
if token_usage > 1000 and output_tokens == 0:
return True
return False
def title(event):
model_id = event.get("modelId", default="unknown")
operation_name = event.get("operation", default="unknown")
account_id = event.get("accountId", default="unknown")
token_usage = event.deep_get("output", "outputBodyJson", "usage", "totalTokens", default=0)
title_parts = [
f"Abnormal token usage detected: {token_usage} tokens",
f"Model: {model_id}",
f"Operation: {operation_name}",
f"Account: {account_id}",
]
return " | ".join(title_parts)
Rule specification
AnalysisType: rule
Filename: aws_bedrockmodelinvocation_abnormaltokenusage.py
RuleID: "AWS.BedrockModelInvocation.AbnormalTokenUsage"
DisplayName: "AWS Bedrock Model Invocation Abnormal Token Usage"
Enabled: true
LogTypes:
- AWS.BedrockModelInvocation
Tags:
- AWS
- Bedrock
- Resource Hijacking
Status: Experimental
Severity: Info
Reports:
MITRE ATT&CK:
- TA0040:T1496.004
Description: Monitors for potential misuse or abuse of AWS Bedrock AI models by detecting abnormal token usage patterns and alerts when the total token usage exceeds the appropriate threshold for each different type of model.
Runbook: Verify the alert details by checking token usage, model ID, and account information to confirm unusual activity, examine user access patterns to identify potential credential compromise, and look for evidence of prompt injection, unusual repetition, or attempts to bypass usage limits. Apply stricter usage quotas to the affected account, block suspicious IP addresses, and enhance the guardrails that are in place.
DedupPeriodMinutes: 60
Threshold: 1
Reference: https://stratus-red-team.cloud/attack-techniques/AWS/aws.impact.bedrock-invoke-model/
SummaryAttributes:
- p_any_aws_account_ids
- p_any_aws_arns
InlineFilters:
- All: []
Stages and Predicates
Fires on AWS.BedrockModelInvocation events when all of the conditions below hold.
Condition
operationis one ofInvokeModel,Converseoutput.outputBodyJson.usage.totalTokensis greater than1000output.outputBodyJson.usage.outputTokensis0
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Indicators
These rows show field, operator, and value matches.
Response runbook
Verify the alert details by checking token usage, model ID, and account information to confirm unusual activity, examine user access patterns to identify potential credential compromise, and look for evidence of prompt injection, unusual repetition, or attempts to bypass usage limits. Apply stricter usage quotas to the affected account, block suspicious IP addresses, and enhance the guardrails that are in place.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"accountId": "111111111111",
"identity": {
"arn": "arn:aws:sts::111111111111:assumed-role/role_details/suspicious.user"
},
"input": {
"inputBodyJson": {
"messages": [
{
"content": [
{
"text": "I have a very suspicious question."
}
],
"role": "user"
}
]
},
"inputContentType": "application/json",
"inputTokenCount": 0
},
"modelId": "anthropic.claude-3-haiku-20240307-v1:0",
"operation": "Converse",
"output": {
"outputBodyJson": {
"metrics": {
"latencyMs": 249
},
"output": {
"message": {
"content": [
{
"text": "You shouldn't ask this question"
}
],
"role": "assistant"
}
},
"usage": {
"inputTokens": 0,
"outputTokens": 0,
"totalTokens": 2000
}
},
"outputContentType": "application/json",
"outputTokenCount": 0
},
"region": "us-west-2",
"requestId": "bb98d9a8-bd9a-47ca-976b-f165ef1f8b67",
"schemaType": "ModelInvocationLog",
"schemaVersion": "1.0",
"timestamp": "2025-05-15 14:17:22.000000000"
}
AWS Bedrock Model Invocation GuardRail Intervened
#Detects when AWS Bedrock guardrail features have intervened during AI model invocations. It specifically monitors when an AI model request was blocked by Guardrails. This helps security teams identify when users attempt to generate potentially harmful or inappropriate content through AWS Bedrock models.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence | No specific technique |
| Credential Access | No specific technique |
Detection logic
def rule(event):
if event.get("operation") != "InvokeModel" and event.get("operation") != "Converse":
return False
stop_reason = event.deep_get(
"output", "outputBodyJSON", "stopReason", default="<UNKNOWN REASON>"
)
action_reason = event.deep_get(
"output",
"outputBodyJSON",
"amazon-bedrock-trace",
"guardrail",
"actionReason",
default="<UNKNOWN ACTION REASON>",
)
return stop_reason == "guardrail_intervened" or action_reason.startswith("Guardrail blocked")
def title(event):
model_id = event.get("modelId")
operation_name = event.get("operation")
account_id = event.get("accountId")
stop_reason = event.deep_get(
"output", "outputBodyJSON", "stopReason", default="<UNKNOWN REASON>"
)
action_reason = event.deep_get(
"output",
"outputBodyJSON",
"amazon-bedrock-trace",
"guardrail",
"actionReason",
default="<UNKNOWN ACTION REASON>",
)
if action_reason == "<UNKNOWN ACTION REASON>":
return (
f"The model [{model_id}] was invoked with the operation [{operation_name}] "
f"by the account [{account_id}]. Stop reason [{stop_reason}]."
)
if stop_reason == "<UNKNOWN REASON>":
return (
f"The model [{model_id}] was invoked with the operation [{operation_name}] "
f"by the account [{account_id}]. Action reason [{action_reason}]."
)
# Handle the case when both values are known
return (
f"The model [{model_id}] was invoked with the operation [{operation_name}] "
f"by the account [{account_id}]. Stop reason [{stop_reason}]. "
f"Action reason [{action_reason}]."
)
Rule specification
AnalysisType: rule
Filename: aws_bedrockmodelinvocation_guardrailintervened.py
RuleID: "AWS.BedrockModelInvocation.GuardRailIntervened"
DisplayName: "AWS Bedrock Model Invocation GuardRail Intervened"
Enabled: true
LogTypes:
- AWS.BedrockModelInvocation
Tags:
- AWS
- Bedrock
- Persistence
- Manipulate AI Model
Status: Experimental
Severity: Info
Reports:
MITRE ATT&CK:
- TA0006:T0018.000
Description: Detects when AWS Bedrock guardrail features have intervened during AI model invocations. It specifically monitors when an AI model request was blocked by Guardrails. This helps security teams identify when users attempt to generate potentially harmful or inappropriate content through AWS Bedrock models.
Runbook: Confirm alert details by reviewing the model ID, operation name, account ID, and the specific guardrail intervention reasons provided in the alert description. Analyze the user prompts that triggered the guardrail by examining the Bedrock console logs for the associated requestId, looking for patterns of attempted model poisoning or prompt injection techniques. If suspicious activity is confirmed, temporarily restrict the access of the malicious actor to Bedrock services, preserve all evidence of the interaction, and escalate to the security team for further analysis of potential AI model manipulation attempts. https://atlas.mitre.org/mitigations/AML.M0005
DedupPeriodMinutes: 60
Threshold: 1
Reference: https://stratus-red-team.cloud/attack-techniques/AWS/aws.impact.bedrock-invoke-model/, https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html
SummaryAttributes:
- p_any_aws_account_ids
- p_any_aws_arns
InlineFilters:
- All: []
Stages and Predicates
Fires on AWS.BedrockModelInvocation events when all of the conditions below hold.
Condition
any of:
operationisInvokeModeloperationisConverse
any of:
output.outputBodyJSON.stopReasonisguardrail_intervenedoutput.outputBodyJSON.amazon-bedrock-trace.guardrail.actionReasonstarts withGuardrail blocked
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
operation | ne | Converse | excludes:operation field:"operation" value:"Converse" |
operation | ne | InvokeModel | excludes:operation field:"operation" value:"InvokeModel" |
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
modelId | |
operation | |
accountId | |
stopReason | output.outputBodyJSON.stopReason |
actionReason | output.outputBodyJSON.amazon-bedrock-trace.guardrail.actionReason |
Response runbook
Confirm alert details by reviewing the model ID, operation name, account ID, and the specific guardrail intervention reasons provided in the alert description. Analyze the user prompts that triggered the guardrail by examining the Bedrock console logs for the associated requestId, looking for patterns of attempted model poisoning or prompt injection techniques. If suspicious activity is confirmed, temporarily restrict the access of the malicious actor to Bedrock services, preserve all evidence of the interaction, and escalate to the security team for further analysis of potential AI model manipulation attempts. https://atlas.mitre.org/mitigations/AML.M0005
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"accountId": "111111111111",
"identity": {
"arn": "arn:aws:sts::111111111111:assumed-role/role_details/suspicious.user"
},
"input": {
"inputBodyJson": {
"messages": [
{
"content": [
{
"text": "I have a very suspicious question."
}
],
"role": "user"
}
]
},
"inputContentType": "application/json",
"inputTokenCount": 0
},
"modelId": "anthropic.claude-3-haiku-20240307-v1:0",
"operation": "Converse",
"output": {
"outputBodyJson": {
"metrics": {
"latencyMs": 249
},
"output": {
"message": {
"content": [
{
"text": "You shouldn't ask this question"
}
],
"role": "assistant"
}
},
"stopReason": "guardrail_intervened",
"usage": {
"inputTokens": 0,
"outputTokens": 0,
"totalTokens": 0
}
},
"outputContentType": "application/json",
"outputTokenCount": 0
},
"region": "us-west-2",
"requestId": "bb98d9a8-bd9a-47ca-976b-f165ef1f8b67",
"schemaType": "ModelInvocationLog",
"schemaVersion": "1.0",
"timestamp": "2025-05-15 14:17:22.000000000"
}
AWS Bedrock Model Invocation Logging Configuration Deleted
#An Amazon Bedrock Model Invocation Logging Configuration was deleted. Use model invocation logging to collect metadata, requests, and responses for all model invocations in your account. Deleting a model invocation logging configuration can have security implications to your AI workloads.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
if (
event.get("eventSource") == "bedrock.amazonaws.com"
and event.get("eventName") == "DeleteModelInvocationLoggingConfiguration"
and aws_cloudtrail_success(event)
):
return True
return False
def title(event):
user = event.udm("actor_user")
return f"User [{user}] deleted Bedrock model invocation logging configuration"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_bedrock_deletemodelinvocationloggingconfiguration.py
RuleID: "AWS.Bedrock.DeleteModelInvocationLoggingConfiguration"
DisplayName: "AWS Bedrock Model Invocation Logging Configuration Deleted"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Bedrock
- "Impair Defenses: Impair Command History Logging"
- Defense Evastion
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0005:T1562.003 # Impair Defenses: Impair Command History Logging
Description: >
An Amazon Bedrock Model Invocation Logging Configuration was deleted.
Use model invocation logging to collect metadata, requests, and responses for all model invocations in your account.
Deleting a model invocation logging configuration can have security implications to your AI workloads.
Runbook: |
Review the model invocation logging configuration deletion to ensure that it was authorized and that it does not introduce security risks to your AI workloads.
If the model invocation logging configuration deletion was unauthorized, investigate the incident and take appropriate action.
Reference: https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisbedrock.amazonaws.comeventNameisDeleteModelInvocationLoggingConfigurationerrorCodeis emptyerrorMessageis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"DeleteModelInvocationLoggingConfiguration" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"bedrock.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
Review the model invocation logging configuration deletion to ensure that it was authorized and that it does not introduce security risks to your AI workloads.
If the model invocation logging configuration deletion was unauthorized, investigate the incident and take appropriate action.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "28773860-a4fd-47c7-a215-6f0e6e6e532f",
"eventName": "DeleteModelInvocationLoggingConfiguration",
"eventSource": "bedrock.amazonaws.com",
"eventTime": "2025-01-21 17:49:47.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.09",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123123123123",
"requestID": "7b9b25ca-be2d-4428-9793-0a677c32b823",
"sessionCredentialFromConsole": true,
"sourceIPAddress": "161.97.249.211",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "bedrock.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"userIdentity": {
"accessKeyId": "ASIAQWERQWERQWERQWER",
"accountId": "123123123123",
"arn": "arn:aws:sts::123123123123:assumed-role/DevAdmin/dr.evil",
"principalId": "AROAQWERQWERQWERQWER:dr.evil",
"sessionContext": {
"attributes": {
"creationDate": "2025-01-21T16:08:03Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "123123123123",
"arn": "arn:aws:iam::123123123123:role/aws-reserved/sso.amazonaws.com/us-west-2/DevAdmin",
"principalId": "AROAQWERQWERQWERQWER",
"type": "Role",
"userName": "DevAdmin"
}
},
"type": "AssumedRole"
}
}
AWS CDE EC2 Volume Encryption
#This policy ensures that all EC2 volumes that contain CDE are encrypted. Be sure to configure CDE definitions before enabling this policy.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
def policy(resource):
return bool(resource["Encrypted"])
Rule specification
AnalysisType: policy
Filename: aws_ec2_cde_volume_encrypted.py
PolicyID: "AWS.EC2.CDEVolumeEncrypted"
DisplayName: "AWS CDE EC2 Volume Encryption"
Enabled: false
ResourceTypes:
- AWS.EC2.Volume
Tags:
- AWS
- PCI
- Collection:Data From Local System
Reports:
PCI:
- 3.4
MITRE ATT&CK:
- TA0009:T1005
Severity: Medium
Description: >
This policy ensures that all EC2 volumes that contain CDE are encrypted.
Be sure to configure CDE definitions before enabling this policy.
Runbook: >
https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#encryption-by-default-api
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html
Stages and Predicates
Flags AWS.EC2.Volume resources when the condition below holds.
Condition
Encryptedis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Encrypted | is_not_null | excludes:Encrypted |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Encrypted | is_null | field:"Encrypted" kind:is_null |
Response runbook
https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#encryption-by-default-api
AWS CloudFormation Stack Drift
#A stack has drifted from its defined configuration.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
from panther_base_helpers import deep_get
# CloudFormation stacks tagged with "STACK=(name)" will be marked as passing.
IGNORE_STACK_TAGS = {
"panther-bootstrap-gateway",
"panther-cloud-security",
"panther-core",
"panther-log-analysis",
}
def policy(resource):
if deep_get(resource, "DriftInformation", "StackDriftStatus") != "DRIFTED":
return True
# Some of Panther's own stacks contain Lambda functions which will always show as "drifted."
# Panther stacks have a fixed "Stack" tag, even though the real stack name is dynamic.
tags = resource["Tags"]
if tags.get("Application") == "Panther" and tags.get("Stack") in IGNORE_STACK_TAGS:
return True
return False
Rule specification
AnalysisType: policy
Filename: aws_cloudformation_stack_drifted.py
PolicyID: "AWS.CloudFormation.Stack.Drifted"
DisplayName: "AWS CloudFormation Stack Drift"
Enabled: true
ResourceTypes:
- AWS.CloudFormation.Stack
Reports:
MITRE ATT&CK:
- TA0040:T1496
Tags:
- AWS
- Operations
- Panther
- Impact:Resource Hijacking
Severity: Low
Description: >
A stack has drifted from its defined configuration.
Runbook: |
From the CloudFormation web console, look at the drifted resources for the failing stack.
If the drift is expected, update the policy ignore list to exclude this stack.
Otherwise, analyze CloudTrail logs to understand who changed the drifted resource(s) and ensure
it was legitimate access.
Reference: https://amzn.to/2z8dDFW
Stages and Predicates
Flags AWS.CloudFormation.Stack resources when all of the conditions below hold.
Condition
DriftInformation.StackDriftStatusisDRIFTEDany of:
Tags.Applicationis notPantherTags.Stackis not one ofpanther-bootstrap-gateway,panther-cloud-security,panther-core,panther-log-analysis
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Tags.Application | eq | Panther | excludes:Tags.Application field:"Tags.Application" value:"Panther" |
Tags.Stack | in | panther-bootstrap-gateway, panther-cloud-security, panther-core, panther-log-analysis | excludes:Tags.Stack |
DriftInformation.StackDriftStatus | ne | DRIFTED | excludes:DriftInformation.StackDriftStatus field:"DriftInformation.StackDriftStatus" value:"DRIFTED" |
Indicators
These rows show field, operator, and value matches.
Response runbook
From the CloudFormation web console, look at the drifted resources for the failing stack.
If the drift is expected, update the policy ignore list to exclude this stack.
Otherwise, analyze CloudTrail logs to understand who changed the drifted resource(s) and ensure
it was legitimate access.
AWS CloudFormation Stack IAM Service Role
#Associating IAM roles with CloudFormation stacks ensures least privilege when making changes to your account.
Detection logic
def policy(resource):
# Ignore stack sets as this setting cannot be set on those
if resource["Name"].startswith("StackSet-"):
return True
return resource["RoleARN"] is not None
Rule specification
AnalysisType: policy
Filename: aws_cloudformation_stack_uses_iam_role.py
PolicyID: "AWS.CloudFormation.Stack.UsesIAMServiceRole"
DisplayName: "AWS CloudFormation Stack IAM Service Role"
Enabled: true
ResourceTypes:
- AWS.CloudFormation.Stack
Tags:
- AWS
- Operations
- Panther
Severity: Info
Description: >
Associating IAM roles with CloudFormation stacks ensures least privilege when making
changes to your account.
Runbook: >
Create a new IAM role to be assumable by the cloudformation service, and then update the
current stack with the --role-arn argument.
Reference: https://amzn.to/2HAdfny
Stages and Predicates
Flags AWS.CloudFormation.Stack resources when all of the conditions below hold.
Condition
Namedoes not start withStackSet-RoleARNis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Name | starts_with | StackSet- | excludes:Name field:"Name" value:"StackSet-" |
RoleARN | is_not_null | excludes:RoleARN |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
RoleARN | is_null | field:"RoleARN" kind:is_null |
Response runbook
Create a new IAM role to be assumable by the cloudformation service, and then update the current stack with the --role-arn argument.
AWS CloudFormation Stack Termination Protection
#Protects a CloudFormation stack from accidentally being deleted. If you attempt to delete a stack with termination protection enabled, the deletion fails and the stack, including its status, will remain unchanged.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
def policy(resource):
# On nested stacks, this can only be set on the root stack
if resource["RootId"] is not None:
return True
return resource["EnableTerminationProtection"] is True
Rule specification
AnalysisType: policy
Filename: aws_cloudformation_termination_protection.py
PolicyID: "AWS.CloudFormation.Stack.TerminationProtection"
DisplayName: "AWS CloudFormation Stack Termination Protection"
Enabled: true
ResourceTypes:
- AWS.CloudFormation.Stack
Reports:
MITRE ATT&CK:
- TA0040:T1496
Tags:
- AWS
- Operations
- Panther
- Impact:Resource Hijacking
Severity: Info
Description: >
Protects a CloudFormation stack from accidentally being deleted. If you attempt to delete a stack
with termination protection enabled, the deletion fails and the stack, including its status,
will remain unchanged.
Runbook: |
This setting can only be enabled on stack creation. To add it to an existing stack, it must
be re-created with the --enable-termination-protection flag.
Reference: https://amzn.to/2HAdfny
Stages and Predicates
Flags AWS.CloudFormation.Stack resources when all of the conditions below hold.
Condition
RootIdis emptyEnableTerminationProtectionis nottrue
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
EnableTerminationProtection | eq | true | excludes:EnableTerminationProtection field:"EnableTerminationProtection" value:"true" |
RootId | is_not_null | excludes:RootId |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
EnableTerminationProtection | ne |
| field:"EnableTerminationProtection" kind:ne value:"true" |
RootId | is_null | field:"RootId" kind:is_null |
Response runbook
This setting can only be enabled on stack creation. To add it to an existing stack, it must
be re-created with the --enable-termination-protection flag.
AWS CloudTrail Account Discovery
#Adversaries may attempt to get a listing of accounts on a system or within an environment. This information can help adversaries determine which accounts exist to aid in follow-on behavior.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Telemetry coverage
Detection logic
DISCOVERY_EVENTS = [
"GetAlternateContact",
"GetContactInformation",
"PutAlternateContact",
"PutContactInformation",
"DescribeAccount",
]
def rule(event):
return event.get("eventName") in DISCOVERY_EVENTS
def title(event):
return (
f"User [{event.deep_get('userIdentity', 'arn')}]"
f"performed a [{event.get('eventName')}] "
f"action in AWS account [{event.get('recipientAccountId')}]."
)
Rule specification
AnalysisType: rule
Description: Adversaries may attempt to get a listing of accounts on a system or within an environment. This information can help adversaries determine which accounts exist to aid in follow-on behavior.
DisplayName: "AWS CloudTrail Account Discovery"
Enabled: true
Filename: aws_cloudtrail_account_discovery.py
Reference: https://attack.mitre.org/techniques/T1087/
Reports:
MITRE ATT&CK:
- TA0007:T1087
Severity: Info
CreateAlert: false
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.CloudTrail.Account.Discovery"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameis one ofGetAlternateContact,GetContactInformation,PutAlternateContact,PutContactInformation,DescribeAccount
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
arn | userIdentity.arn |
eventName | |
recipientAccountId |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "0b51d284-19f7-42cf-a103-276602aeada5",
"eventName": "DescribeAccount",
"eventSource": "organizations.amazonaws.com",
"eventTime": "2022-11-21 18:06:52",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_any_aws_account_ids": [
"123456789123"
],
"p_any_aws_arns": [
"arn:aws:iam::123456789123:role/TestUser",
"arn:aws:sts::123456789123:assumed-role/TestUser/test_123456789123"
],
"p_any_ip_addresses": [
"1.1.1.1"
],
"p_any_trace_ids": [
"ASIA3JHVJH35KB7LJHV2"
],
"p_any_usernames": [
"TestUser"
],
"p_event_time": "2022-11-21 18:06:52",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2022-11-21 18:07:38.9",
"p_row_id": "824956f0377f98908684d8de14d3d612",
"p_source_id": "5f9f0f60-9c56-4027-b93a-8bab3019f0f1",
"p_source_label": "Cloudtrail",
"readOnly": true,
"recipientAccountId": "123456789123",
"requestID": "1c40241b-c59c-4d4a-8301-b612545f9c5c",
"requestParameters": {
"accountId": "123456789123"
},
"sourceIPAddress": "1.1.1.1",
"tlsDetails": {
"cipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"clientProvidedHostHeader": "organizations.us-east-1.amazonaws.com",
"tlsVersion": "TLSv1.2"
},
"userAgent": "Boto3/1.26.2 Python/3.10.8 Linux/4.14.294-220.533.amzn2.x86_64 exec-env/AWS_ECS_FARGATE Botocore/1.29.2",
"userIdentity": {
"accessKeyId": "ASIA3JHVJH35KB7LJHV2",
"accountId": "123456789123",
"arn": "arn:aws:sts::123456789123:assumed-role/TestUser/test_123456789123",
"principalId": "AR0A354LKJXC87G9XC89V:test_123456789123",
"sessionContext": {
"attributes": {
"creationDate": "2022-11-21T18:06:36Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "123456789123",
"arn": "arn:aws:iam::123456789123:role/TestUser",
"principalId": "AR0A354LKJXC87G9XC89V",
"type": "Role",
"userName": "TestUser"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS CloudTrail Attempt To Leave Org
#Detects when an actor attempts to remove an AWS account from an Organization. Security configurations are often defined at the organizational level. Leaving the organization can disrupt or totally shut down these controls.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_core import PantherEvent
def rule(event: PantherEvent) -> bool:
return event.get("eventName") == "LeaveOrganization"
def title(event: PantherEvent) -> str:
account_name = event.get("recipientAccountId")
actor = event.udm("actor_user")
# Return a more informative message if the attempt was unsuccessful
if not aws_cloudtrail_success(event):
return f"Failed attempt to remove {account_name} from your AWS organization by {actor}"
return f"Account {account_name} has been removed from your AWS organization by {actor}"
def severity(event: PantherEvent) -> str:
# Downgrade to HIGH if attempt is unsuccessful
if not aws_cloudtrail_success(event):
return "HIGH"
return "DEFAULT"
def alert_context(event: PantherEvent) -> dict:
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_attempt_to_leave_org.py
RuleID: "AWS.CloudTrail.AttemptToLeaveOrg"
DisplayName: AWS CloudTrail Attempt To Leave Org
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
Reports:
MITRE ATT&CK:
- TA0005:T1562.008 # Defense Evasion: Impair Defenses - Disable or Modify Cloud Logs
- TA0005:T1666 # Defense Evasion: Modify Cloud Resource Hierarchy
Stratus Red Team:
- aws.defense-evasion.organizations-leave
Description: >
Detects when an actor attempts to remove an AWS account from an Organization. Security
configurations are often defined at the organizational level. Leaving the organization can
disrupt or totally shut down these controls.
Reference: >
https://stratus-red-team.cloud/attack-techniques/AWS/aws.defense-evasion.organizations-leave/
Runbook: |
Determine if the attempt was successful. Monitor and potentially suspect the user account which
attempted the action. Determine if the root account is compromised.
SummaryAttributes:
- p_any_ip_addresses
- p_any_aws_account_ids
Tags:
- AWS CloudTrail
- Defense Evasion
- Impair Defenses
- Disable or Modify Cloud Logs
- Modify Cloud Resource Hierarchy
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisLeaveOrganization
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"LeaveOrganization" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
Determine if the attempt was successful. Monitor and potentially suspect the user account which
attempted the action. Determine if the root account is compromised.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"errorCode": "AccessDenied",
"errorMessage": "User: arn:aws:sts::111122223333:assumed-role/SampleRole/SampleSession is not authorized to perform: organizations:LeaveOrganization on resource: * because no identity-based policy allows the organizations:LeaveOrganization action",
"eventCategory": "Management",
"eventID": "f52c1358-4ddb-4453-a676-3f4dbc64d713",
"eventName": "LeaveOrganization",
"eventSource": "organizations.amazonaws.com",
"eventTime": "2025-01-20 15:59:33.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.09",
"managementEvent": true,
"p_event_time": "2025-01-20 15:59:33.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2025-01-20 16:05:54.322564138",
"readOnly": false,
"recipientAccountId": "111122223333",
"requestID": "67dce4b9-c7d1-4c91-a686-d34bbd5365eb",
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "organizations.us-east-1.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "example-user-agent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/SampleSession",
"principalId": "SAMPLE_PRINCIPAL_ID:SampleSession",
"sessionContext": {
"attributes": {
"creationDate": "2025-01-20T15:59:30Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS CloudTrail CloudWatch Logs
#CloudTrail supports sending data and management events to CloudWatch Logs. This setup can be used for real-time processing of all CloudTrail data events.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
import datetime
from panther_base_helpers import deep_get, resolve_timestamp_string
MAX_TIME_BETWEEN_LOGS = datetime.timedelta(hours=24)
def policy(resource):
# Check if a CloudWatch Logs Group has been set, and received at least one log
if not (
resource.get("CloudWatchLogsLogGroupArn")
and deep_get(resource, "Status", "LatestCloudWatchLogsDeliveryTime")
):
return False
# Check if the last log sent is within the allowable timeframe
last_log_time = resolve_timestamp_string(
deep_get(resource, "Status", "LatestCloudWatchLogsDeliveryTime")
)
if not last_log_time:
return True
return (datetime.datetime.utcnow() - last_log_time) <= MAX_TIME_BETWEEN_LOGS
Rule specification
AnalysisType: policy
Filename: aws_cloudtrail_cloudwatch_logs.py
PolicyID: "AWS.CloudTrail.CloudWatchLogs"
DisplayName: "AWS CloudTrail CloudWatch Logs"
Enabled: false
ResourceTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 2.4
MITRE ATT&CK:
- TA0005:T1562
Severity: Low
Description: >
CloudTrail supports sending data and management events to CloudWatch Logs. This setup can be
used for real-time processing of all CloudTrail data events.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-cloudtrail-trails-integrated-with-cloudwatch-logs
Reference: >
https://docs.aws.amazon.com/awscloudtrail/latest/userguide/send-cloudtrail-events-to-cloudwatch-logs.html
Stages and Predicates
Flags AWS.CloudTrail resources when any of the conditions below holds.
Condition
any of:
CloudWatchLogsLogGroupArnis emptyStatus.LatestCloudWatchLogsDeliveryTimeis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
CloudWatchLogsLogGroupArn | is_not_null | excludes:CloudWatchLogsLogGroupArn | |
Status.LatestCloudWatchLogsDeliveryTime | is_not_null | excludes:Status.LatestCloudWatchLogsDeliveryTime |
Indicators
These rows show field, operator, and value matches.
Response runbook
AWS CloudTrail Least Privilege Access
#Users with permissions to disable or reconfigure CloudTrail should be limited.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
CLOUDTRAIL_MANAGED_POLICY_ARN = "arn:aws:iam::aws:policy/AWSCloudTrailFullAccess"
MAX_ADMIN_USERS = 2
def policy(resource):
return (
CLOUDTRAIL_MANAGED_POLICY_ARN in resource["ManagedPolicyARNs"]
and len(resource["Users"]) <= MAX_ADMIN_USERS
)
Rule specification
AnalysisType: policy
Filename: aws_cloudtrail_least_privilege.py
PolicyID: "AWS.CloudTrail.LeastPrivilege"
DisplayName: "AWS CloudTrail Least Privilege Access"
Enabled: false
ResourceTypes:
- AWS.IAM.Group
Tags:
- AWS
- Configuration Required
- IAM
- Panther
- Defense Evasion:Impair Defenses
Reports:
MITRE ATT&CK:
- TA0005:T1562
Severity: Medium
Description: >
Users with permissions to disable or reconfigure CloudTrail should be limited.
Runbook: >
Remove the AWSCloudTrailFullAccess managed IAM policy from all but one user in the account.
Reference: https://amzn.to/2ZEUrKm
Stages and Predicates
Flags AWS.IAM.Group resources when any of the conditions below holds.
Condition
any of:
ManagedPolicyARNsdoes not containarn:aws:iam::aws:policy/AWSCloudTrailFullAccessUsershas length greater than2
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
ManagedPolicyARNs | contains | arn:aws:iam::aws:policy/AWSCloudTrailFullAccess | excludes:ManagedPolicyARNs field:"ManagedPolicyARNs" value:"arn:aws:iam::aws:policy/AWSCloudTrailFullAccess" |
Users | length_compare | 2 | excludes:Users field:"Users" value:"2" |
Response runbook
Remove the AWSCloudTrailFullAccess managed IAM policy from all but one user in the account.
AWS CloudTrail Log Encryption
#This policy validates that CloudTrail Logs are encrypted at rest with customer managed KMS key.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Detection logic
def policy(resource):
return bool(resource["KmsKeyId"])
Rule specification
AnalysisType: policy
Filename: aws_cloudtrail_log_encryption.py
PolicyID: "AWS.CloudTrail.LogEncryption"
DisplayName: "AWS CloudTrail Log Encryption"
Enabled: true
ResourceTypes:
- AWS.CloudTrail
Tags:
- AWS
- Data Protection
- Discovery:Cloud Service Discovery
Reports:
CIS:
- 2.7
MITRE ATT&CK:
- TA0007:T1526
Severity: Medium
Description: >
This policy validates that CloudTrail Logs are encrypted at rest with customer managed KMS key.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-cloudtrail-logs-encrypted-using-kms-cmk
Reference: >
https://docs.aws.amazon.com/awscloudtrail/latest/userguide/encrypting-cloudtrail-log-files-with-aws-kms.html
Stages and Predicates
Flags AWS.CloudTrail resources when the condition below holds.
Condition
KmsKeyIdis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
KmsKeyId | is_not_null | excludes:KmsKeyId |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
KmsKeyId | is_null | field:"KmsKeyId" kind:is_null |
Response runbook
AWS CloudTrail Log Validation
#This policy ensures that CloudTrail logs have file integrity validation enabled.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
def policy(resource):
# Explicit check for True as the value may be None, and we want to return a bool not a NoneType
return resource["LogFileValidationEnabled"] is True
Rule specification
AnalysisType: policy
Filename: aws_cloudtrail_log_validation.py
PolicyID: "AWS.CloudTrail.LogValidation"
DisplayName: "AWS CloudTrail Log Validation"
Enabled: true
ResourceTypes:
- AWS.CloudTrail
Tags:
- AWS
- Data Protection
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 2.2
PCI:
- 10.5.5
MITRE ATT&CK:
- TA0005:T1562
Severity: Medium
Description: >
This policy ensures that CloudTrail logs have file integrity validation enabled.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-cloudtrail-log-validation-enabled
Reference: >
https://amzn.to/2MMgE6W
Stages and Predicates
Flags AWS.CloudTrail resources when the condition below holds.
Condition
LogFileValidationEnabledis nottrue
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
LogFileValidationEnabled | eq | true | excludes:LogFileValidationEnabled field:"LogFileValidationEnabled" value:"true" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
LogFileValidationEnabled | ne |
| field:"LogFileValidationEnabled" kind:ne value:"true" |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-cloudtrail-log-validation-enabled
AWS CloudTrail Management Events Enabled
#This policy ensures that at least one CloudTrail has management (control plane) operations logged.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
def policy(resource):
# pylint: disable=R1260
if not resource.get("Trails"):
return False
if not any(
[resource.get("GlobalEventSelectors"), resource.get("GlobalAdvancedEventSelectors")]
):
return False
if resource.get("GlobalEventSelectors"):
for selector in resource.get("GlobalEventSelectors", [{}]):
if selector.get("IncludeManagementEvents") and selector.get("ReadWriteType") == "All":
return True
if resource.get("GlobalAdvancedEventSelectors"):
for advanced_selector in resource.get("GlobalAdvancedEventSelectors", [{}]):
management_present = False
readonly_present = False
for field_selector in advanced_selector.get("FieldSelectors", [{}]):
if field_selector.get("Field") == "eventCategory":
event_categories = field_selector.get("Equals", [])
if "Management" in event_categories:
management_present = True
if field_selector.get("Field") == "readOnly":
readonly_present = True
if all([management_present, not readonly_present]):
return True
return False
Rule specification
AnalysisType: policy
Filename: aws_cloudtrail_enabled.py
PolicyID: "AWS.CloudTrail.Enabled"
DisplayName: "AWS CloudTrail Management Events Enabled"
Enabled: true
ResourceTypes:
- AWS.CloudTrail.Meta
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 2.1
PCI:
- 10.5.4
MITRE ATT&CK:
- TA0005:T1562
Severity: High
Description: >
This policy ensures that at least one CloudTrail has
management (control plane) operations logged.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-cloudtrail-enabled-in-all-regions
Reference: >
https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-user-guide.html
Stages and Predicates
Flags AWS.CloudTrail.Meta resources when any of the conditions below holds.
Condition
any of:
Trailsis emptyall of:
GlobalEventSelectorsis emptyGlobalAdvancedEventSelectorsis empty
all of:
any of:
GlobalEventSelectorsis emptyno element of
GlobalEventSelectorsmatches all of:GlobalEventSelectors.IncludeManagementEventsis presentGlobalEventSelectors.ReadWriteTypeisAll
GlobalAdvancedEventSelectorsis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
GlobalEventSelectors | array_any | excludes:GlobalEventSelectors | |
GlobalEventSelectors | is_not_null | excludes:GlobalEventSelectors | |
GlobalAdvancedEventSelectors | is_not_null | excludes:GlobalAdvancedEventSelectors | |
Trails | is_not_null | excludes:Trails |
Indicators
These rows show field, operator, and value matches.
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-cloudtrail-enabled-in-all-regions
AWS CloudTrail Password Policy Discovery
#This detection looks for *AccountPasswordPolicy events in AWS CloudTrail logs. If these events occur in a short period of time from the same ARN, it could constitute Password Policy reconnaissance.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Password Policy Changes (Splunk)
Detection logic
from panther_aws_helpers import aws_rule_context
PASSWORD_DISCOVERY_EVENTS = [
"GetAccountPasswordPolicy",
"UpdateAccountPasswordPolicy",
"PutAccountPasswordPolicy",
]
def rule(event):
service_event = event.get("eventType") == "AwsServiceEvent"
return event.get("eventName") in PASSWORD_DISCOVERY_EVENTS and not service_event
def title(event):
user_arn = event.deep_get("useridentity", "arn", default="<MISSING_ARN>")
return f"Password Policy Discovery detected in AWS CloudTrail from [{user_arn}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: This detection looks for *AccountPasswordPolicy events in AWS CloudTrail logs. If these events occur in a short period of time from the same ARN, it could constitute Password Policy reconnaissance.
DisplayName: AWS CloudTrail Password Policy Discovery
Enabled: true
Filename: aws_cloudtrail_password_policy_discovery.py
Reports:
MITRE ATT&CK:
- TA0007:T1201
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_account-policy.html
Severity: Info
DedupPeriodMinutes: 30
LogTypes:
- AWS.CloudTrail
RuleID: AWS.CloudTrail.Password.Policy.Discovery
Threshold: 2
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameis one ofGetAccountPasswordPolicy,UpdateAccountPasswordPolicy,PutAccountPasswordPolicyeventTypeis notAwsServiceEvent
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventType | ne |
| field:"eventType" kind:ne value:"AwsServiceEvent" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | useridentity.arn |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsregion": "us-east-1",
"eventcategory": "Management",
"eventid": "1808ca3b-4311-4b48-9d1f-21061acb2329",
"eventname": "GetAccountPasswordPolicy",
"eventsource": "iam.amazonaws.com",
"eventtime": "2023-01-10 23:10:06",
"eventtype": "AwsApiCall",
"eventversion": "1.08",
"managementevent": true,
"useridentity": {
"arn": "arn:aws:test_arn"
}
}
AWS CloudTrail Password Spraying
#Detects password spraying attacks by alerting when more than 9 distinct usernames fail to authenticate to the AWS console from the same account and region within 60 minutes.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventType") != "AwsConsoleSignIn":
return False
return event.deep_get("responseElements", "ConsoleLogin", default="") == "Failure"
def title(event):
account = event.get("recipientAccountId", "Unknown Account")
region = event.get("awsRegion", "Unknown Region")
return f"Password Spraying Detected in AWS Account [{account}] Region [{region}]"
def dedup(event):
account = event.get("recipientAccountId", "")
region = event.get("awsRegion", "")
return f"{account}:{region}"
def unique(event):
return event.deep_get("userIdentity", "userName") or None
def severity(event):
if event.deep_get("userIdentity", "type", default="") == "Root":
return "HIGH"
return "DEFAULT"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_password_spraying.py
RuleID: "AWS.CloudTrail.PasswordSpraying"
DisplayName: "AWS CloudTrail Password Spraying"
Status: Experimental
Enabled: false
Severity: Medium
DedupPeriodMinutes: 60
Threshold: 10
LogTypes:
- AWS.CloudTrail
Description: >
Detects password spraying attacks by alerting when more than 9 distinct usernames
fail to authenticate to the AWS console from the same account and region within 60 minutes.
Reports:
MITRE ATT&CK:
- TA0001:T1078
Tags:
- Initial Access:Valid Accounts
Runbook: |
1. Query CloudTrail for all ConsoleLogin events in the 2 hours around this alert grouped by sourceIPAddress to identify the origin of the spray targeting recipientAccountId
2. Check if any of the targeted usernames subsequently had a successful ConsoleLogin from any sourceIPAddress in the 6 hours after the alert
3. Find other alerts for this recipientAccountId or any of the targeted usernames in the past 7 days to determine if this is part of a broader campaign
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventTypeisAwsConsoleSignInresponseElements.ConsoleLoginisFailure
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventType | eq |
| field:"eventType" kind:eq value:"AwsConsoleSignIn" |
responseElements.ConsoleLogin | eq |
| field:"responseElements.ConsoleLogin" kind:eq value:"Failure" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
1. Query CloudTrail for all ConsoleLogin events in the 2 hours around this alert grouped by sourceIPAddress to identify the origin of the spray targeting recipientAccountId
2. Check if any of the targeted usernames subsequently had a successful ConsoleLogin from any sourceIPAddress in the 6 hours after the alert
3. Find other alerts for this recipientAccountId or any of the targeted usernames in the past 7 days to determine if this is part of a broader campaign
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventName": "ConsoleLogin",
"eventSource": "signin.amazonaws.com",
"eventType": "AwsConsoleSignIn",
"recipientAccountId": "111122223333",
"responseElements": {
"ConsoleLogin": "Failure"
},
"userIdentity": {
"type": "IAMUser",
"userName": "alice"
}
}
AWS Cloudtrail Region Enabled
#Threat actors who successfully compromise a victim's AWS account, whether through stolen credentials, exposed access keys, exploited IAM misconfigurations, vulnerabilities in third-party applications, or the absence of Multi-Factor Authentication (MFA), can exploit unused regions as safe zones for malicious activities. These regions are often overlooked in monitoring and security setups, making them an attractive target for attackers to operate undetected.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Stealth |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return event.get("eventName") == "EnableRegion"
def title(event):
return (
f"AWS CloudTrail region [{event.deep_get('requestParameters', 'RegionName')}] "
f"enabled by user [{event.udm('actor_user')}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
DedupPeriodMinutes: 60
DisplayName: AWS Cloudtrail Region Enabled
Enabled: true
Filename: aws_cloudtrail_region_enabled.py
RuleID: "AWS.CloudTrail.EnableRegion"
Severity: Medium
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- CloudTrail
- Region
Reports:
MITRE ATT&CK:
- TA0005:T1535 # Unused/Unsupported Cloud Regions
Description: >
Threat actors who successfully compromise a victim's AWS account, whether through stolen credentials,
exposed access keys, exploited IAM misconfigurations, vulnerabilities in third-party applications,
or the absence of Multi-Factor Authentication (MFA), can exploit unused regions as safe zones
for malicious activities. These regions are often overlooked in monitoring and security setups,
making them an attractive target for attackers to operate undetected.
Runbook: |
Validate whether enabling the new region was authorized.
Revoke user privileges, review the newly enabled region for malicious activity, and disable the region.
Reference: https://permiso.io/blog/how-threat-actors-leverage-unsupported-cloud-regions
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisEnableRegion
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"EnableRegion" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
RegionName | requestParameters.RegionName |
actor_user |
Response runbook
Validate whether enabling the new region was authorized.
Revoke user privileges, review the newly enabled region for malicious activity, and disable the region.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"CipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"SignatureVersion": "SigV4",
"bytesTransferredIn": 0,
"bytesTransferredOut": 0
},
"awsRegion": "us-east-1",
"eventID": "EXAMPLE-1234-5678-9012-EXAMPLE",
"eventName": "EnableRegion",
"eventSource": "cloudtrail.amazonaws.com",
"eventTime": "2023-10-01T12:34:56Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": false,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "EXAMPLE123456789",
"requestParameters": {
"RegionName": "us-west-2"
},
"resources": [],
"responseElements": null,
"sharedEventID": "EXAMPLE-1234-5678-9012-EXAMPLE",
"sourceIPAddress": "192.0.2.0",
"userAgent": "aws-sdk-go/1.15.12 (go1.12.6; linux; amd64)",
"userIdentity": {
"accessKeyId": "EXAMPLEKEY",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/Alice",
"principalId": "EXAMPLE",
"type": "IAMUser",
"userName": "Alice"
},
"vpcEndpointId": "vpce-1a2b3c4d"
}
AWS CloudTrail Retention Lifecycle Too Short
#Detects when an S3 bucket containing CloudTrail logs has been modified to delete data after a short period of time.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_base_helpers import deep_get
# Use this to record the names of your S3 buckets that have cloudtrail logs
# If a bucket name isn't mentioned here, we still make a best guess as to whether or not it
# contains CloudTrail data, but the confidence rating will be lower, and so will the severity
CLOUDTRAIL_BUCKETS = ("example_cloudtrail_bucket_name",)
# This is the minimum length fo time CloudTrail logs should remain in an S3 bucket.
# We set this to 7 initially, since this is the recommended amount of time logs ingested by
# Panther should remain available. You can modify this if you wish.
CLOUDTRAIL_MINIMUM_STORAGE_PERIOD_DAYS = 7
def rule(event):
# Only alert for successful PutBucketLifecycle events
if not (aws_cloudtrail_success(event) and event.get("eventName") == "PutBucketLifecycle"):
return False
# Exit out if the bucket doesn't have cloudtrail logs
# We check this be either comparing the bucket name to a list of buckets the user knows has
# CT logs, or by heuristically looking at the name and guessing whether it likely has CT logs
bucket_name = event.deep_get("requestParameters", "bucketName")
if not bucket_name or (
not is_cloudtrail_bucket(bucket_name) and not guess_is_cloudtrail_bucket(bucket_name)
):
return False
# Don't alert if the Rule status is disabled
lifecycle = event.deep_get("requestParameters", "LifecycleConfiguration", "Rule")
if lifecycle.get("Status") != "Enabled":
return False
# Alert if the lifecycle period is short
duration = deep_get(lifecycle, "Expiration", "Days", default=0)
return duration < CLOUDTRAIL_MINIMUM_STORAGE_PERIOD_DAYS
def title(event):
bucket_name = event.deep_get("requestParameters", "bucketName", default="<UNKNOWN S3 BUCKET>")
lifecycle = event.deep_get("requestParameters", "LifecycleConfiguration", "Rule")
duration = deep_get(lifecycle, "Expiration", "Days", default=0)
rule_id = lifecycle.get("ID", "<UNKNOWN RULE ID>")
account = event.deep_get("userIdentity", "accountId", default="<UNKNOWN_AWS_ACCOUNT>")
return (
f"S3 Bucket {bucket_name} in account {account} "
f"has new rule {rule_id} set to delete CloudTrail logs after "
f"{duration} day{'s' if duration != 1 else ''}"
)
def severity(event):
# Return lower severity if we aren't positive this bucket has cloudtrail logs.
bucket_name = event.deep_get("requestParameters", "bucketName")
if not is_cloudtrail_bucket(bucket_name):
return "LOW"
return "DEFAULT"
def alert_context(event):
context = aws_rule_context(event)
# Add name of S3 bucket, Rule ID, and expiration duration to context
bucket_name = event.deep_get("requestParameters", "bucketName", default="<UNKNOWN S3 BUCKET>")
lifecycle = event.deep_get("requestParameters", "LifecycleConfiguration", "Rule")
duration = deep_get(lifecycle, "Expiration", "Days", default=0)
rule_id = lifecycle.get("ID", "<UNKNOWN RULE ID>")
context.update(
{
"bucketName": bucket_name,
"lifecycleRuleID": rule_id,
"lifecycleRuleDurationDays": duration,
}
)
return context
def is_cloudtrail_bucket(bucket_name: str) -> bool:
"""Returns True if the bucket is known to contain CloudTrail logs."""
return bucket_name in CLOUDTRAIL_BUCKETS
def guess_is_cloudtrail_bucket(bucket_name: str) -> bool:
"""Takes a best guess at whether a bucket contains CloudTrail logs or not."""
# Maybe one day, this check will get more complex
return "trail" in bucket_name.lower()
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_short_lifecycle.py
RuleID: "AWS.CloudTrail.ShortLifecycle"
DisplayName: "AWS CloudTrail Retention Lifecycle Too Short"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
Reports:
MITRE ATT&CK:
- TA0005:T1562.008 # Defense Evasion: Disable or Modify Cloud Logs
Description: "Detects when an S3 bucket containing CloudTrail logs has been modified to delete data after a short period of time."
Reference:
https://stratus-red-team.cloud/attack-techniques/AWS/aws.defense-evasion.cloudtrail-lifecycle-rule/
Runbook: Verify whether the bucket in question contains CloudTrail data, and if so, why the lifecycle was changed. Potentally add a filter for this bucket to prevent future false positives.
Tags:
- AWS
- Cloudtrail
- Defense Evasion
- Impair Defenses
- Disable or Modify Cloud Logs
- Defense Evasion:Impair Defenses
- Security Control
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisPutBucketLifecyclerequestParameters.bucketNameis presentany of:
requestParameters.bucketNameis one ofexample_cloudtrail_bucket_namerequestParameters.bucketNamecontainstrail
requestParameters.LifecycleConfiguration.Rule.StatusisEnabledrequestParameters.LifecycleConfiguration.Rule.Expiration.Daysis less than7
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
requestParameters.bucketName | contains | trail | excludes:requestParameters.bucketName field:"requestParameters.bucketName" value:"trail" |
requestParameters.bucketName | eq | example_cloudtrail_bucket_name | excludes:requestParameters.bucketName field:"requestParameters.bucketName" value:"example_cloudtrail_bucket_name" |
requestParameters.bucketName | is_null | excludes:requestParameters.bucketName | |
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
bucketName | requestParameters.bucketName |
accountId | userIdentity.accountId |
ID | requestParameters.LifecycleConfiguration.Rule.ID |
Days | requestParameters.LifecycleConfiguration.Rule.Expiration.Days |
Response runbook
Verify whether the bucket in question contains CloudTrail data, and if so, why the lifecycle was changed. Potentally add a filter for this bucket to prevent future false positives.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"AuthenticationMethod": "AuthHeader",
"CipherSuite": "TLS_AES_128_GCM_SHA256",
"SignatureVersion": "SigV4",
"bytesTransferredIn": 249,
"bytesTransferredOut": 0,
"x-amz-id-2": "vf6Ehji6uE8ET3EJvRpIQva7eul9KSAUWVlf87sIKBmLQ0HgdGbswZiHYlVvSr1FdP5DiZze4DRZRAFppKpD4A=="
},
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "e1ea136d-f372-4cd5-be5f-f317fc80214a",
"eventName": "PutBucketLifecycle",
"eventSource": "s3.amazonaws.com",
"eventTime": "2024-11-25 22:00:58.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.10",
"managementEvent": true,
"p_event_time": "2024-11-25 22:00:58.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2024-11-25 22:05:54.357893092",
"readOnly": false,
"recipientAccountId": "111122223333",
"requestID": "4XRNRGRFH6RES629",
"requestParameters": {
"Host": "sample-cloudtrail-bucket-name.s3.us-west-2.amazonaws.com",
"LifecycleConfiguration": {
"Rule": {
"Expiration": {
"Days": 1
},
"Filter": {
"Prefix": "*"
},
"ID": "nuke-cloudtrail-logs-after-1-day",
"Status": "Enabled"
},
"xmlns": "http://s3.amazonaws.com/doc/2006-03-01/"
},
"bucketName": "sample-cloudtrail-bucket-name",
"lifecycle": ""
},
"resources": [
{
"accountId": "111122223333",
"arn": "arn:aws:s3:::sample-cloudtrail-bucket-name",
"type": "AWS::S3::Bucket"
}
],
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "sample-cloudtrail-bucket-name.s3.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "[sample-user-agent]",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/leroy.jenkins",
"principalId": "SAMPLE_PRINCIPAL_ID:leroy.jenkins",
"sessionContext": {
"attributes": {
"creationDate": "2024-11-25T16:53:42Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS CloudTrail S3 Bucket Access Logging
#This policy validates that the bucket receiving CloudTrail Logs is configured with S3 Access Logging. This audits all creation, modification, or deletion to CloudTrail audit logs.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
from panther_aws_helpers import BadLookup, resource_lookup
def policy(resource):
bucket_arn = "arn:aws:s3:::" + resource["S3BucketName"]
try:
bucket = resource_lookup(bucket_arn)
except BadLookup:
return True
return bucket["LoggingPolicy"] is not None
Rule specification
AnalysisType: policy
Filename: aws_cloudtrail_s3_bucket_access_logging.py
PolicyID: "AWS.CloudTrail.S3Bucket.AccessLogging"
DisplayName: "AWS CloudTrail S3 Bucket Access Logging"
Enabled: true
ResourceTypes:
- AWS.CloudTrail
Tags:
- AWS
- Data Protection
- Collection:Data From Cloud Storage Object
Reports:
CIS:
- 2.6
MITRE ATT&CK:
- TA0009:T1530
Severity: Medium
Description: >
This policy validates that the bucket receiving CloudTrail Logs is configured with
S3 Access Logging. This audits all creation, modification, or deletion to CloudTrail audit logs.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-cloudtrail-s3-bucket-has-access-logging-enabled
Reference: >
https://docs.aws.amazon.com/AmazonS3/latest/dev/ServerLogs.html
# Unit testing not supported for policies that might network calls
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
AWS CloudTrail S3 Bucket Public
#This policy validates that CloudTrail S3 buckets are not publicly accessible.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
from panther_aws_helpers import BadLookup, aws_regions, resource_lookup
from panther_base_helpers import deep_get
BAD_PERMISSIONS = {
"http://acs.amazonaws.com/groups/global/AuthenticatedUsers",
"http://acs.amazonaws.com/groups/global/AllUsers",
}
# docs.aws.amazon.com/codepipeline/latest/userguide/reference-ct-placeholder-buckets.html
EXCLUDED_BUCKET_NAMES = {
f"codepipeline-cloudtrail-placeholder-bucket-{region}" for region in aws_regions()
}
def policy(resource):
bucket_arn = "arn:aws:s3:::" + resource["S3BucketName"]
try:
bucket = resource_lookup(bucket_arn)
except BadLookup:
return True
for grant in bucket["Grants"] or []:
if deep_get(grant, "Grantee", "URI") in BAD_PERMISSIONS and not any(
bucket_name in bucket_arn for bucket_name in EXCLUDED_BUCKET_NAMES
):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_cloudtrail_s3_bucket_public.py
PolicyID: "AWS.CloudTrail.S3Bucket.Public"
DisplayName: "AWS CloudTrail S3 Bucket Public"
Enabled: true
ResourceTypes:
- AWS.CloudTrail
Tags:
- AWS
- Data Protection
- Collection:Data From Cloud Storage Object
Reports:
CIS:
- 2.3
MITRE ATT&CK:
- TA0009:T1530
Severity: High
Description: >
This policy validates that CloudTrail S3 buckets are not publicly accessible.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-cloudtrail-logs-s3-bucket-not-publicly-accessible
Reference: >
https://docs.aws.amazon.com/AmazonS3/latest/user-guide/block-public-access.html
# Unit testing not supported for policies that might network calls
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
AWS CloudTrail SES Check Identity Verifications
#Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
from panther_core import PantherEvent
def rule(event: PantherEvent) -> bool:
return event.get("eventName") == "GetIdentityVerificationAttributes"
def alert_context(event: PantherEvent) -> dict:
context = aws_rule_context(event)
context["accountRegion"] = f"{event.get('recipientAccountId')}_{event.get('eventRegion')}"
return context
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_ses_check_identity_verifications.py
RuleID: "AWS.CloudTrail.SES.CheckIdentityVerifications"
DisplayName: AWS CloudTrail SES Check Identity Verifications
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
CreateAlert: false
Reference: >
https://stratus-red-team.cloud/attack-techniques/AWS/aws.discovery.ses-enumerate/
Tags:
- AWS CloudTrail
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisGetIdentityVerificationAttributes
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"GetIdentityVerificationAttributes" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"SignatureVersion": "4"
},
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "05197e93-992f-4476-899a-a6f53c9a462c",
"eventName": "GetIdentityVerificationAttributes",
"eventSource": "ses.amazonaws.com",
"eventTime": "2025-01-20 16:52:14.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_event_time": "2025-01-20 16:52:14.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2025-01-20 17:00:54.142940079",
"readOnly": true,
"recipientAccountId": "111122223333",
"requestID": "e3b6e034-97ce-4d43-a7d2-1e718f3ebf32",
"requestParameters": {
"identities": [
"acme.com",
"bobson.dugnutt@acme.com",
"sleve.mcdichael@yahoo.com"
]
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "email.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "example-user-agent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/bobson.dugnutt",
"principalId": "SAMPLE_PRINCIPAL_ID:bobson.dugnutt",
"sessionContext": {
"attributes": {
"creationDate": "2025-01-20T15:58:59Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS CloudTrail SES Check Send Quota
#Detect when someone checks how many emails can be delivered via SES. Excludes automated checks from AWS Trusted Advisor to reduce false positives.
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
from panther_core import PantherEvent
def rule(event: PantherEvent) -> bool:
if event.get("eventName") == "GetSendQuota":
# Exclude AWS Trusted Advisor automated checks
role_name = event.deep_get("userIdentity", "sessionContext", "sessionIssuer", "userName")
if role_name == "AWSServiceRoleForTrustedAdvisor":
return False
return True
return False
def alert_context(event: PantherEvent) -> dict:
context = aws_rule_context(event)
context["accountRegion"] = f"{event.get('recipientAccountId')}_{event.get('eventRegion')}"
return context
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_ses_check_send_quota.py
RuleID: "AWS.CloudTrail.SES.CheckSendQuota"
DisplayName: AWS CloudTrail SES Check Send Quota
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
CreateAlert: false
Description: >
Detect when someone checks how many emails can be delivered via SES.
Excludes automated checks from AWS Trusted Advisor to reduce false positives.
Reference: >
https://stratus-red-team.cloud/attack-techniques/AWS/aws.discovery.ses-enumerate/
Tags:
- AWS CloudTrail
- SES
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisGetSendQuotauserIdentity.sessionContext.sessionIssuer.userNameis notAWSServiceRoleForTrustedAdvisor
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"GetSendQuota" |
userIdentity.sessionContext.sessionIssuer.userName | ne |
| field:"userIdentity.sessionContext.sessionIssuer.userName" kind:ne value:"AWSServiceRoleForTrustedAdvisor" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"SignatureVersion": "4"
},
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "141c7b0f-3ec3-40bd-b551-5a33d1a794b4",
"eventName": "GetSendQuota",
"eventSource": "ses.amazonaws.com",
"eventTime": "2025-01-20 16:52:14.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_event_time": "2025-01-20 16:52:14.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2025-01-20 17:00:54.217261818",
"readOnly": true,
"recipientAccountId": "111122223333",
"requestID": "6495a102-3900-47fc-a8b4-88e4b4e56442",
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "email.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "example-user-agent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/bobson.dugnutt",
"principalId": "SAMPLE_PRINCIPAL_ID:bobson.dugnutt",
"sessionContext": {
"attributes": {
"creationDate": "2025-01-20T15:58:59Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS CloudTrail SES Check SES Sending Enabled
#Detect when a user inquires whether SES Sending is enabled.
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
from panther_core import PantherEvent
def rule(event: PantherEvent) -> bool:
return event.get("eventName") == "GetAccountSendingEnabled"
def alert_context(event: PantherEvent) -> dict:
context = aws_rule_context(event)
context["accountRegion"] = f"{event.get('recipientAccountId')}_{event.get('eventRegion')}"
return context
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_ses_check_ses_sending_enabled.py
RuleID: "AWS.CloudTrail.SES.CheckSESSendingEnabled"
DisplayName: AWS CloudTrail SES Check SES Sending Enabled
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
CreateAlert: false
Description: >
Detect when a user inquires whether SES Sending is enabled.
Reference: >
https://stratus-red-team.cloud/attack-techniques/AWS/aws.discovery.ses-enumerate/
Tags:
- AWS CloudTrail
- SES
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisGetAccountSendingEnabled
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"GetAccountSendingEnabled" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "910326f5-5c2c-49b4-a963-702280f29208",
"eventName": "GetAccountSendingEnabled",
"eventSource": "ses.amazonaws.com",
"eventTime": "2025-01-20 16:52:14.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_event_time": "2025-01-20 16:52:14.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2025-01-20 17:00:54.143061055",
"readOnly": true,
"recipientAccountId": "111122223333",
"requestID": "b88b794d-b419-47b0-9805-5af1de78a1e7",
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "email.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "example-user-agent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/bobson.dugnutt",
"principalId": "SAMPLE_PRINCIPAL_ID:bobson.dugnutt",
"sessionContext": {
"attributes": {
"creationDate": "2025-01-20T15:58:59Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS CloudTrail SES Enumeration
#MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Rule specification
AnalysisType: correlation_rule
RuleID: AWS.CloudTrail.SES.SESEnumeration
DisplayName: AWS CloudTrail SES Enumeration
Enabled: true
Severity: Medium
Detection:
- Group:
- ID: CheckSendingEnabled
RuleID: AWS.CloudTrail.SES.CheckSESSendingEnabled
- ID: CheckSendQuota
RuleID: AWS.CloudTrail.SES.CheckSendQuota
- ID: ListIdentities
RuleID: AWS.CloudTrail.SES.ListIdentities
- ID: CheckVerifications
RuleID: AWS.CloudTrail.SES.CheckIdentityVerifications
MatchCriteria:
accountRegion:
- GroupID: CheckSendingEnabled
Match: p_alert_context.accountRegion
- GroupID: CheckSendQuota
Match: p_alert_context.accountRegion
- GroupID: ListIdentities
Match: p_alert_context.accountRegion
- GroupID: CheckVerifications
Match: p_alert_context.accountRegion
LookbackWindowMinutes: 2160
Schedule:
RateMinutes: 1440
TimeoutMinutes: 2
Reference:
https://stratus-red-team.cloud/attack-techniques/AWS/aws.discovery.ses-enumerate/
Reports:
MITRE ATT&CK:
- TA0007:T1580
SummaryAttributes:
- p_any_aws_arns
- p_any_ip_addresses
- p_any_emails
- p_any_actor_ids
Tags:
- AWS
- SES
- Discovery
- Cloud Service Discovery
Stages and Predicates
Fires when the steps below all occur within 36h, correlated by p_alert_context.accountRegion. Each step needs one match unless a higher minimum is shown.
Stage 1: step CheckSendingEnabled
References detection AWS CloudTrail SES Check SES Sending Enabled.
Stage 2: step CheckSendQuota
References detection AWS CloudTrail SES Check Send Quota.
Stage 3: step ListIdentities
References detection AWS CloudTrail SES List Identities.
Stage 4: step CheckVerifications
References detection AWS CloudTrail SES Check Identity Verifications.
AWS CloudTrail SES List Identities
#Detection logic
from panther_aws_helpers import aws_rule_context
from panther_core import PantherEvent
def rule(event: PantherEvent) -> bool:
return event.get("eventName") == "ListIdentities"
def alert_context(event: PantherEvent) -> dict:
context = aws_rule_context(event)
context["accountRegion"] = f"{event.get('recipientAccountId')}_{event.get('eventRegion')}"
return context
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_ses_list_identities.py
RuleID: "AWS.CloudTrail.SES.ListIdentities"
DisplayName: AWS CloudTrail SES List Identities
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
CreateAlert: false
Reference: >
https://stratus-red-team.cloud/attack-techniques/AWS/aws.discovery.ses-enumerate/
Tags:
- AWS CloudTrail
- SES
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisListIdentities
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"ListIdentities" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"SignatureVersion": "4"
},
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "7c41bbec-52c5-49cb-80aa-88f295d490fd",
"eventName": "ListIdentities",
"eventSource": "ses.amazonaws.com",
"eventTime": "2025-01-20 16:52:14.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_event_time": "2025-01-20 16:52:14.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2025-01-20 17:00:54.217385551",
"readOnly": true,
"recipientAccountId": "111122223333",
"requestID": "7bdf32e1-6e53-4752-b745-2cb37788a23c",
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "email.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "example-user-agent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/bobson.dugnutt",
"principalId": "SAMPLE_PRINCIPAL_ID:bobson.dugnutt",
"sessionContext": {
"attributes": {
"creationDate": "2025-01-20T15:58:59Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS CloudWatch Log Encryption
#AWS automatically performs server-side encryption of logs, but you can encrypt with your own CMK to protect extra sensitive log data.
Detection logic
def policy(resource):
return resource["KmsKeyId"] is not None
Rule specification
AnalysisType: policy
Filename: aws_cloudwatch_loggroup_encrypted.py
PolicyID: "AWS.CloudWatchLogs.Encrypted"
DisplayName: "AWS CloudWatch Log Encryption"
Enabled: true
ResourceTypes:
- AWS.CloudWatch.LogGroup
Tags:
- AWS
- Panther
Severity: Info
Description: >
AWS automatically performs server-side encryption of logs, but you can encrypt with your own CMK
to protect extra sensitive log data.
Runbook: >
Encrypt the CloudWatch log group with a KMS key, or add this log group to the ignore list.
Reference: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/encrypt-log-data-kms.html
Stages and Predicates
Flags AWS.CloudWatch.LogGroup resources when the condition below holds.
Condition
KmsKeyIdis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
KmsKeyId | is_not_null | excludes:KmsKeyId |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
KmsKeyId | is_null | field:"KmsKeyId" kind:is_null |
Response runbook
Encrypt the CloudWatch log group with a KMS key, or add this log group to the ignore list.
AWS CloudWatch Logs Data Retention
#By default, logs are kept indefinitely and never expire. You can adjust the retention policy for each log group, keeping the indefinite retention, or choosing a specific retention period.
Detection logic
MIN_DAYS_TO_RETAIN_LOGS = 365
def policy(resource):
retention_in_days = resource["RetentionInDays"]
# If retention is None, logs will never expire.
return retention_in_days is None or retention_in_days >= MIN_DAYS_TO_RETAIN_LOGS
Rule specification
AnalysisType: policy
Filename: aws_cloudwatch_loggroup_data_retention.py
PolicyID: "AWS.CloudWatchLogs.DataRetention1Year"
DisplayName: "AWS CloudWatch Logs Data Retention"
Enabled: true
ResourceTypes:
- AWS.CloudWatch.LogGroup
Tags:
- AWS
- Panther
Severity: Low
Description: >
By default, logs are kept indefinitely and never expire. You can adjust the retention policy
for each log group, keeping the indefinite retention, or choosing a specific retention period.
Runbook: |
Change the CloudWatch log group retention from the CloudWatch Logs web console,
SDK, CloudFormation, or any other supported method.
Reference: https://docs.aws.amazon.com/cli/latest/reference/logs/put-retention-policy.html
Stages and Predicates
Flags AWS.CloudWatch.LogGroup resources when all of the conditions below hold.
Condition
RetentionInDaysis presentRetentionInDaysis less than365
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
RetentionInDays | ge | 365 | excludes:RetentionInDays field:"RetentionInDays" value:"365" |
RetentionInDays | is_null | excludes:RetentionInDays |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
RetentionInDays | is_not_null | field:"RetentionInDays" kind:is_not_null | |
RetentionInDays | lt |
| field:"RetentionInDays" kind:lt value:"365" |
Response runbook
Change the CloudWatch log group retention from the CloudWatch Logs web console,
SDK, CloudFormation, or any other supported method.
AWS Compromised IAM Key Quarantine
#Detects when an IAM user has the AWSCompromisedKeyQuarantineV2 policy attached to their account.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Credential Access |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS IAM AdministratorAccess Policy Attached to Group (Elastic)
- AWS IAM AdministratorAccess Policy Attached to Role (Elastic)
- AWS IAM AdministratorAccess Policy Attached to User (Elastic)
- AWS IAM Customer-Managed Policy Attached to Role by Rare User (Elastic)
- AWS IAM Sensitive Operations via Lambda Execution Role (Elastic)
- AWS Sensitive IAM Operations Performed via CloudShell (Elastic)
- IAM Admin Policy Attached (Sigma)
- IAM Policy Attachment Attempt (Sigma)
Detection logic
IAM_ACTIONS = {
"AttachUserPolicy",
"AttachGroupPolicy",
"AttachRolePolicy",
}
QUARANTINE_MANAGED_POLICY = "arn:aws:iam::aws:policy/AWSCompromisedKeyQuarantineV2"
def rule(event):
return all(
[
event.get("eventSource", "") == "iam.amazonaws.com",
event.get("eventName", "") in IAM_ACTIONS,
event.deep_get("requestParameters", "policyArn", default="")
== QUARANTINE_MANAGED_POLICY,
]
)
def title(event):
account_id = event.get("recipientAccountId", "<ACCOUNT_ID_NOT_FOUND>")
user_name = event.deep_get("requestParameters", "userName", default="<USER_NAME_NOT_FOUND>")
return f"Compromised Key quarantined for [{user_name}] in AWS Account [{account_id}]"
Rule specification
AnalysisType: rule
LogTypes:
- AWS.CloudTrail
Description: "Detects when an IAM user has the AWSCompromisedKeyQuarantineV2 policy attached to their account."
DisplayName: "AWS Compromised IAM Key Quarantine"
Enabled: true
RuleID: "AWS.CloudTrail.IAMCompromisedKeyQuarantine"
Filename: aws_iam_compromised_key_quarantine.py
Severity: High
Tags:
- AWS
- Identity and Access Management
- Initial Access:Valid Accounts
- Credential Access:Unsecured Credentials
Reports:
MITRE ATT&CK:
- TA0001:T1078.004
- TA0006:T1552.001
Runbook: >
Check the quarantined IAM entity's key usage for signs of compromise and follow the instructions outlined in the AWS support case opened regarding this event.
Reference: https://unit42.paloaltonetworks.com/malicious-operations-of-exposed-iam-keys-cryptojacking/
Threshold: 1
DedupPeriodMinutes: 60
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisiam.amazonaws.comeventNameis one ofAttachUserPolicy,AttachGroupPolicy,AttachRolePolicyrequestParameters.policyArnisarn:aws:iam::aws:policy/AWSCompromisedKeyQuarantineV2
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"iam.amazonaws.com" |
requestParameters.policyArn | eq |
| field:"requestParameters.policyArn" kind:eq value:"arn:aws:iam::aws:policy/AWSCompromisedKeyQuarantineV2" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
userName | requestParameters.userName |
recipientAccountId |
Response runbook
Check the quarantined IAM entity's key usage for signs of compromise and follow the instructions outlined in the AWS support case opened regarding this event.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "e7bb4b23-66e1-4656-b607-f575fde3b790",
"eventName": "AttachUserPolicy",
"eventSource": "iam.amazonaws.com",
"eventTime": "2023-11-21T23:23:52Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a2468e00-2b3c-4696-8056-327a624b5887",
"requestParameters": {
"policyArn": "arn:aws:iam::aws:policy/AWSCompromisedKeyQuarantineV2",
"userName": "test-user"
},
"responseElements": null,
"sessionCredentialFromConsole": "true",
"sourceIPAddress": "1.2.3.4",
"userAgent": "AWS Internal",
"userIdentity": {
"accessKeyId": "FAKE_ACCESS_KEY",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/a-role/user.name",
"principalId": "FAKE_PRINCIPAL:user.name",
"sessionContext": {
"attributes": {
"creationDate": "2023-11-21T22:28:31Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/a-role",
"principalId": "FAKE_PRINCIPAL",
"type": "Role",
"userName": "a-role"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Config Global Resources
#You can have AWS Config record supported types of global resources, such as IAM users, groups, roles, and customer managed policies.
Detection logic
import json
from panther_aws_helpers import BadLookup, resource_lookup
from panther_base_helpers import deep_get
# TODO: Once Detection Pipelines are merged, implement downgraded (INFO) case for multiple
# global resource recorders.
def policy(resource):
if (
resource.get("GlobalRecorderCount", 0) == 0
or "Recorders" not in resource
or not bool(resource.get("Recorders"))
):
return False
for recorder_name in resource.get("Recorders", []):
try:
recorder = resource_lookup(recorder_name)
except BadLookup:
continue
if isinstance(recorder, str):
recorder = json.loads(recorder)
resource_records_global_resources = bool(
deep_get(recorder, "RecordingGroup", "IncludeGlobalResourceTypes")
and deep_get(recorder, "Status", "Recording")
)
if resource_records_global_resources:
return True
return False
Rule specification
AnalysisType: policy
Filename: aws_config_global_resources.py
PolicyID: "AWS.Config.GlobalResources"
DisplayName: "AWS Config Global Resources"
Enabled: true
ResourceTypes:
- AWS.Config.Recorder.Meta
Tags:
- AWS
- Security Control
Severity: Low
Description: >
You can have AWS Config record supported types of global resources, such as
IAM users, groups, roles, and customer managed policies.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-config-is-enabled-for-global-resources
Reference: https://amzn.to/2MO8xXM
Stages and Predicates
Flags AWS.Config.Recorder.Meta resources when any of the conditions below holds.
Condition
any of:
GlobalRecorderCountis0Recordersis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
GlobalRecorderCount | eq | 0 | excludes:GlobalRecorderCount field:"GlobalRecorderCount" value:"0" |
Recorders | is_null | excludes:Recorders |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
GlobalRecorderCount | eq |
| field:"GlobalRecorderCount" kind:eq value:"0" |
Recorders | is_null | field:"Recorders" kind:is_null |
Response runbook
AWS Config Recording Status
#This policy ensures that the config recorder is operational and capturing changes to your account without error.
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
return deep_get(resource, "Status", "LastErrorCode") is None
Rule specification
AnalysisType: policy
Filename: aws_config_recording_no_error.py
PolicyID: "AWS.Config.RecordingNoErrors"
DisplayName: "AWS Config Recording Status"
Enabled: true
ResourceTypes:
- AWS.Config.Recorder
Tags:
- AWS
- Panther
Severity: Medium
Description: >
This policy ensures that the config recorder is operational and capturing changes to
your account without error.
Runbook: >
Check the AWS Config console to understand and resolve the cause of the errors.
Reference: https://docs.aws.amazon.com/config/latest/developerguide/notification-delivery-failed.html
Stages and Predicates
Flags AWS.Config.Recorder resources when the condition below holds.
Condition
Status.LastErrorCodeis present
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Status.LastErrorCode | is_null | excludes:Status.LastErrorCode |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Status.LastErrorCode | is_not_null | field:"Status.LastErrorCode" kind:is_not_null |
Response runbook
Check the AWS Config console to understand and resolve the cause of the errors.
AWS Config Records All Resource Types
#This policy ensurers that you have a comprehensive configuration audit in place for all resource types in AWS.
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
return bool(deep_get(resource, "RecordingGroup", "AllSupported"))
Rule specification
AnalysisType: policy
Filename: aws_config_all_resource_types.py
PolicyID: "AWS.Config.RecordAllResourceTypes"
DisplayName: "AWS Config Records All Resource Types"
Enabled: true
ResourceTypes:
- AWS.Config.Recorder
Tags:
- AWS
- Panther
Severity: Low
Description: >
This policy ensurers that you have a comprehensive configuration audit in place for
all resource types in AWS.
Runbook: >
Update AWS Config to record changes to all supported resource types.
Reference: https://aws.amazon.com/blogs/mt/aws-config-best-practices/
Stages and Predicates
Flags AWS.Config.Recorder resources when the condition below holds.
Condition
RecordingGroup.AllSupportedis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
RecordingGroup.AllSupported | is_not_null | excludes:RecordingGroup.AllSupported |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
RecordingGroup.AllSupported | is_null | field:"RecordingGroup.AllSupported" kind:is_null |
Response runbook
Update AWS Config to record changes to all supported resource types.
AWS Config Service Created
#An AWS Config Recorder or Delivery Channel was created
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of an AWS Config Service change
CONFIG_SERVICE_CREATE_EVENTS = {
"PutDeliveryChannel",
"PutConfigurationRecorder",
"StartConfigurationRecorder",
}
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") in CONFIG_SERVICE_CREATE_EVENTS
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_config_service_created.py
RuleID: "AWS.ConfigService.Created"
DisplayName: "AWS Config Service Created"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Discovery:Cloud Service Discovery
Reports:
CIS:
- 3.9
MITRE ATT&CK:
- TA0007:T1526
Severity: Info
Description: >
An AWS Config Recorder or Delivery Channel was created
Runbook: >
Verify that the Config Service changes were authorized. If not, revert them and investigate who caused the change. Consider altering permissions to prevent this from happening again in the future.
Reference: https://aws.amazon.com/config/
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofPutDeliveryChannel,PutConfigurationRecorder,StartConfigurationRecorder
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Verify that the Config Service changes were authorized. If not, revert them and investigate who caused the change. Consider altering permissions to prevent this from happening again in the future.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "PutDeliveryChannel",
"eventSource": "config.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"configurationRecorderName": "default"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Config Service Disabled
#An AWS Config Recorder or Delivery Channel was disabled or deleted
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- Account Security Configuration Changed (Panther)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of an AWS Config Service change
CONFIG_SERVICE_DISABLE_DELETE_EVENTS = {
"StopConfigurationRecorder",
"DeleteDeliveryChannel",
}
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventName") in CONFIG_SERVICE_DISABLE_DELETE_EVENTS
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_config_service_disabled_deleted.py
RuleID: "AWS.ConfigService.DisabledDeleted"
DisplayName: "AWS Config Service Disabled"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 3.9
MITRE ATT&CK:
- TA0005:T1562
Severity: Medium
Description: >
An AWS Config Recorder or Delivery Channel was disabled or deleted
Runbook: >
Verify that the Config Service changes were authorized. If not, revert them and investigate who caused the change. Consider altering permissions to prevent this from happening again in the future.
Reference: https://aws.amazon.com/config/
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofStopConfigurationRecorder,DeleteDeliveryChannel
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Verify that the Config Service changes were authorized. If not, revert them and investigate who caused the change. Consider altering permissions to prevent this from happening again in the future.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "DeleteDeliveryChannel",
"eventSource": "config.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"configurationRecorderName": "default"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Config Status
#This policy ensures that the config recorder is operational and capturing changes to your account.
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
return deep_get(resource, "Status", "Recording", default=False)
Rule specification
AnalysisType: policy
Filename: aws_config_recording_enabled.py
PolicyID: "AWS.Config.RecordingEnabled"
DisplayName: "AWS Config Status"
Enabled: true
ResourceTypes:
- AWS.Config.Recorder
Tags:
- AWS
- Panther
Severity: High
Description: >
This policy ensures that the config recorder is operational and capturing changes to
your account.
Runbook: Enable AWS Config recording
Reference: https://docs.aws.amazon.com/config/latest/developerguide/stop-start-recorder.html
Stages and Predicates
Flags AWS.Config.Recorder resources when the condition below holds.
Condition
Status.Recordingis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Status.Recording | is_not_null | excludes:Status.Recording |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Status.Recording | is_null | field:"Status.Recording" kind:is_null |
Response runbook
Enable AWS Config recording
AWS Console GetSigninToken Potential Abuse
#Detects GetSigninToken calls from non-SSO user agents. An adversary can use tools like aws_consoler to convert compromised CLI credentials into a federated console session, bypassing MFA requirements and obscuring the original access key. The GetSigninToken API creates temporary console access from STS temporary credentials.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Lateral Movement |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
# User agents associated with legitimate AWS SSO portal sign-in token requests
SSO_USER_AGENTS = ["Jersey/${project.version}", "Go-http-client/2.0"]
def rule(event):
if event.get("eventSource") != "signin.amazonaws.com":
return False
if event.get("eventName") != "GetSigninToken":
return False
# Exclude legitimate AWS SSO portal traffic
user_agent = event.get("userAgent", "")
for sso_ua in SSO_USER_AGENTS:
if sso_ua in user_agent:
return False
return True
def title(event):
arn = event.deep_get("userIdentity", "arn", default="<unknown>")
ip_addr = event.get("sourceIPAddress", "<unknown>")
user_agent = event.get("userAgent", "<unknown>")
return (
f"Suspicious GetSigninToken call from [{ip_addr}] "
f"as [{arn}] with user agent [{user_agent}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_console_getsignintoken.py
RuleID: "AWS.Console.GetSigninToken.Abuse"
DisplayName: "AWS Console GetSigninToken Potential Abuse"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- AWS STS
- Lateral Movement:Remote Services
- Defense Evasion:Use Alternate Authentication Material
Reports:
MITRE ATT&CK:
- TA0008:T1021.007
- TA0005:T1550.001
Status: Experimental
Severity: Medium
Description: >
Detects GetSigninToken calls from non-SSO user agents. An adversary can use tools
like aws_consoler to convert compromised CLI credentials into a federated console
session, bypassing MFA requirements and obscuring the original access key. The
GetSigninToken API creates temporary console access from STS temporary credentials.
Runbook: |
1. Query CloudTrail for all API calls by userIdentity:arn in the 6 hours before and after this alert, focusing on ConsoleLogin and console-based actions that may indicate the federated session was used
2. Check if sourceIPAddress and userAgent are associated with known internal tooling or if they match patterns of attacker tools like aws_consoler
3. Find all other alerts from this userIdentity:accessKeyId in the past 7 days to determine if the underlying credentials are compromised
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceissignin.amazonaws.comeventNameisGetSigninTokenuserAgentdoes not containJersey/${project.version}userAgentdoes not containGo-http-client/2.0
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
userAgent | contains | Go-http-client/2.0 | excludes:userAgent field:"userAgent" value:"Go-http-client/2.0" |
userAgent | contains | Jersey/${project.version} | excludes:userAgent field:"userAgent" value:"Jersey/${project.version}" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"GetSigninToken" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"signin.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Response runbook
1. Query CloudTrail for all API calls by userIdentity:arn in the 6 hours before and after this alert, focusing on ConsoleLogin and console-based actions that may indicate the federated session was used
2. Check if sourceIPAddress and userAgent are associated with known internal tooling or if they match patterns of attacker tools like aws_consoler
3. Find all other alerts from this userIdentity:accessKeyId in the past 7 days to determine if the underlying credentials are compromised
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventName": "GetSigninToken",
"eventSource": "signin.amazonaws.com",
"eventTime": "2024-01-15T10:30:00Z",
"eventType": "AwsApiCall",
"recipientAccountId": "123456789012",
"sourceIPAddress": "203.0.113.50",
"userAgent": "python-requests/2.28.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/MyRole/session",
"type": "AssumedRole"
}
}
AWS Console Login
#Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Console Login Failed During MFA Challenge (Splunk)
- AWS ConsoleLogin Failed Authentication (Sigma)
- AWS CreateLoginProfile (Splunk)
- AWS Credential Access Failed Login (Splunk)
- AWS High Number Of Failed Authentications For User (Splunk)
- AWS High Number Of Failed Authentications From Ip (Splunk)
- AWS Multiple Failed MFA Requests For User (Splunk)
- AWS Multiple Users Failing To Authenticate From Ip (Splunk)
Detection logic
def rule(event):
return event.get("eventName") == "ConsoleLogin"
def alert_context(event):
context = {}
context["ip_and_username"] = event.get(
"sourceIPAddress", "<MISSING_SOURCE_IP>"
) + event.deep_get("userIdentity", "userName", default="<MISSING_USER_NAME>")
return context
Rule specification
AnalysisType: rule
Filename: aws_console_login.py
RuleID: "AWS.Console.Login"
DisplayName: "AWS Console Login"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
DedupPeriodMinutes: 60
Threshold: 1
CreateAlert: false
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisConsoleLogin
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"ConsoleLogin" |
AWS Console Sign-In WITHOUT Okta Redirect
#A user has logged into the AWS console without authenticating via Okta. This rule requires AWS SSO via Okta and both log sources configured.
Rule specification
AnalysisType: correlation_rule
RuleID: "AWS.Console.Sign-In.WITHOUT.Okta"
DisplayName: "AWS Console Sign-In WITHOUT Okta Redirect"
Enabled: false
Tags:
- AWS
- Configuration Required
- Okta
- Actor Profiles
Severity: High
Description: A user has logged into the AWS console without authenticating via Okta. This rule requires AWS SSO via Okta and both log sources configured.
Detection:
- Group:
- ID: Okta SSO to AWS
RuleID: Okta.SSO.to.AWS
Absence: true
- ID: AWS Console Sign-In
RuleID: AWS.Console.Sign-In
MatchCriteria:
field_name:
- GroupID: Okta SSO to AWS
Match: p_alert_context.actor
- GroupID: AWS Console Sign-In
Match: userIdentity.userName
Schedule:
RateMinutes: 1440
TimeoutMinutes: 5
LookbackWindowMinutes: 1800
Stages and Predicates
Fires when the steps below all occur within 30h, correlated by p_alert_context.actor, userIdentity.userName. Each step needs one match unless a higher minimum is shown.
Stage 1: step Okta SSO to AWS (negated)
References detection SIGNAL - Okta SSO to AWS.
Stage 2: step AWS Console Sign-In
References detection SIGNAL - AWS Console SSO Sign-In.
AWS Decrypt SSM Parameters
#Identify principals retrieving a high number of SSM Parameters of type 'SecretString'. This rule filters out known administrative roles that legitimately need bulk parameter access.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
import datetime as dt
import json
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_core import PantherEvent
from panther_detection_helpers.caching import get_string_set, put_string_set
# Determine how many secrets must be accessed in order to trigger an alert
PARAM_THRESHOLD = 25
# Whitelisted IAM role name patterns (case-insensitive)
WHITELISTED_ROLE_PATTERNS = [
"AWSReservedSSO_DevAdmin_", # SSO admin roles
"AWSReservedSSO_Admin_", # SSO admin roles
]
all_param_names = set()
def rule(event: PantherEvent) -> bool:
# Exclude events of the wrong type
if not (
event.get("eventName") in ("GetParameter", "GetParameters")
and event.deep_get("requestParameters", "withDecryption")
):
return False
# Check if the role is whitelisted
role_name = event.deep_get(
"userIdentity", "sessionContext", "sessionIssuer", "userName", default=""
)
if role_name and any(
pattern.lower() in role_name.lower() for pattern in WHITELISTED_ROLE_PATTERNS
):
return False
# Determine if this actor accessed any other params in this account
key = get_cache_key(event)
cached_params = get_cached_param_names(key)
accessed_params = get_param_names(event)
# Determine if the cache needs updating with new entries
global all_param_names # pylint: disable=global-statement
all_param_names = cached_params | accessed_params
if all_param_names - cached_params:
# Only set the TTL if this is the first time we're adding to the cache
# Otherwise we'll be perpetually extending the lifespan of the cached data every time we
# add more.
put_string_set(key, all_param_names, epoch_seconds=(3600 if not cached_params else None))
# Check combined number of params
return len(all_param_names) > PARAM_THRESHOLD
def title(event: PantherEvent) -> str:
actor = event.udm("actor_user")
account_name = event.get("recipientAccountId")
return f"Excessive SSM parameter decryption by [{actor}] in [{account_name}]"
def severity(event: PantherEvent) -> str:
# Demote to LOW if attempt was denied
if not aws_cloudtrail_success(event):
return "LOW"
return "DEFAULT"
def alert_context(event: PantherEvent) -> dict:
global all_param_names
context = aws_rule_context(event)
context.update({"accessedParams": list(all_param_names)})
return context
def get_cache_key(event) -> str:
"""Use the field values in the event to generate a cache key unique to this actor and
account ID."""
offset = (
dt.datetime.fromisoformat(event.get("p_event_time", "1970-01-01T00:00:00")).timestamp()
// 3600
* 3600
)
actor = event.udm("actor_user")
account = event.get("recipientAccountId")
rule_id = "AWS.SSM.DecryptSSMParams"
return f"{rule_id}-{account}-{actor}-{offset}"
def get_param_names(event) -> set[str]:
"""Returns the accessed SSM Param names."""
# Params could be either a list or a single entry
params = set(event.deep_get("requestParameters", "names", default=[]))
if single_param := event.deep_get("requestParameters", "name"):
params.add(single_param)
return params
def get_cached_param_names(key: str) -> set[str]:
"""Get any previously cached parameter names. Included automatic converstion from string in
the case of a unit test mock."""
cached_params = get_string_set(key, force_ttl_check=True)
if isinstance(cached_params, str):
# This is a unit test
cached_params = set(json.loads(cached_params))
return cached_params
Rule specification
AnalysisType: rule
Filename: aws_ssm_decrypt_ssm_params.py
RuleID: "AWS.SSM.DecryptSSMParams"
DisplayName: AWS Decrypt SSM Parameters
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0006:T1555
Stratus Red Team:
- aws.credential-access.ssm-retrieve-securestring-parameters
Description: >
Identify principals retrieving a high number of SSM Parameters of type 'SecretString'.
This rule filters out known administrative roles that legitimately need bulk parameter access.
Threshold: 25
Reference: >
https://stratus-red-team.cloud/attack-techniques/AWS/aws.credential-access.ssm-retrieve-securestring-parameters/
Runbook: |
1. Query CloudTrail for all GetParameter and GetParameters events by userIdentity.arn with requestParameters.withDecryption=true in the 4 hours around this alert to identify the complete list of accessed SSM parameters
2. Review the parameter names from the resources array to determine if they contain database credentials, API keys, or encryption keys, and assess the impact if those secrets are compromised
3. Search CloudTrail for other suspicious API calls by the same userIdentity.arn and sourceIPAddress in the 24 hours before the first parameter access, looking for privilege escalation, IAM changes, or unusual resource access
SummaryAttributes:
- sourceIpAddress
- p_alert_context.accessedParams
Tags:
- AWS CloudTrail
- 'Credential Access: Credentials from Password Stores'
Status: Experimental
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameis one ofGetParameter,GetParametersrequestParameters.withDecryptionis presentuserIdentity.sessionContext.sessionIssuer.userNameis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
userIdentity.sessionContext.sessionIssuer.userName | is_not_null | excludes:userIdentity.sessionContext.sessionIssuer.userName |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
requestParameters.withDecryption | is_not_null | field:"requestParameters.withDecryption" kind:is_not_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
1. Query CloudTrail for all GetParameter and GetParameters events by userIdentity.arn with requestParameters.withDecryption=true in the 4 hours around this alert to identify the complete list of accessed SSM parameters
2. Review the parameter names from the resources array to determine if they contain database credentials, API keys, or encryption keys, and assess the impact if those secrets are compromised
3. Search CloudTrail for other suspicious API calls by the same userIdentity.arn and sourceIPAddress in the 24 hours before the first parameter access, looking for privilege escalation, IAM changes, or unusual resource access
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "587e6d58-a653-4fd9-859f-367dc1bad98c",
"eventName": "GetParameter",
"eventSource": "ssm.amazonaws.com",
"eventTime": "2025-02-14 19:43:09.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.11",
"managementEvent": true,
"p_event_time": "2025-02-14 19:43:09.000000000",
"p_log_type": "AWS.CloudTrail",
"readOnly": true,
"recipientAccountId": "111122223333",
"requestID": "a1f28efd-9f5b-4a13-9878-86f57de594dc",
"requestParameters": {
"name": "/credentials/stratus-red-team/credentials-25",
"withDecryption": true
},
"resources": [
{
"accountId": "111122223333",
"arn": "arn:aws:ssm:us-west-2:111122223333:parameter/credentials/stratus-red-team/credentials-25"
}
],
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"clientProvidedHostHeader": "ssm.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.2"
},
"userAgent": "example-user-agent",
"userIdentity": {
"accessKeyId": "EXAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/bobson.dugnutt",
"principalId": "SAMPLE_PRINCIPAL_ID:bobson.dugnutt",
"sessionContext": {
"attributes": {
"creationDate": "2025-02-14T19:42:05Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS DNS Crypto Domain
#Identifies clients that may be performing DNS lookups associated with common currency mining pools.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
from panther_iocs import CRYPTO_MINING_DOMAINS
def rule(event):
query_name = event.udm("dns_query")
if not query_name:
return False
for domain in CRYPTO_MINING_DOMAINS:
if query_name.rstrip(".").endswith(domain):
return True
return False
def title(event):
return (
f"[{event.udm('source_ip')}:{event.udm('source_port')}] "
"made a DNS query for crypto mining domain: "
f"[{event.udm('dns_query')}]."
)
def dedup(event):
return f"{event.udm('source_ip')}"
Rule specification
AnalysisType: rule
Description: Identifies clients that may be performing DNS lookups associated with common currency mining pools.
DisplayName: "AWS DNS Crypto Domain"
Enabled: true
Filename: aws_dns_crypto_domain.py
Reports:
MITRE ATT&CK:
- TA0040:T1496
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html
Severity: High
DedupPeriodMinutes: 60
LogTypes:
- AWS.VPCDns
- OCSF.DnsActivity
RuleID: "AWS.DNS.Crypto.Domain"
Threshold: 1
Stages and Predicates
Fires on AWS.VPCDns, OCSF.DnsActivity events when all of the conditions below hold.
Condition
dns_queryis presentany of:
dns_queryends with1gh.comdns_queryends withabcxyz.streamdns_queryends withalimabi.cndns_queryends withap.luckpool.netdns_queryends withasiapool.iodns_queryends withbackup-pool.comdns_queryends withbaikalmine.comdns_queryends withbcn.pool.minergate.comdns_queryends withbcn.vip.pool.minergate.comdns_queryends withbohemianpool.comdns_queryends withca.minexmr.comdns_queryends withca.monero.herominers.comdns_queryends withcbd.monerpool.orgdns_queryends withcbdv2.monerpool.orgdns_queryends withcoinfoundry.orgdns_queryends withcoinpoolit.webhop.medns_queryends withcoolmining.clubdns_queryends withcryptmonero.comdns_queryends withcrypto-pool.frdns_queryends withcrypto-pool.infodns_queryends withcrypto-pools.orgdns_queryends withcryptoescrow.eudns_queryends withcryptoknight.ccdns_queryends withcryptonight-hub.miningpoolhub.comdns_queryends withcryptonight.netdns_queryends withcryptonotepool.org.ukdns_queryends withcryptonotepool.orgdns_queryends withd1pool.ddns.netdns_queryends withd5pool.usdns_queryends withdaili01.monerpool.orgdns_queryends withde.minexmr.comdns_queryends withdl.nbminer.comdns_queryends withdo-dear.comdns_queryends withdonate.graef.indns_queryends withdonate.ssl.xmrig.comdns_queryends withdonate.v2.xmrig.comdns_queryends withdonate.xmrig.comdns_queryends withdonate2.graef.indns_queryends withdrill.moneroworld.comdns_queryends withdwarfpool.comdns_queryends withemercoin.comdns_queryends withemercoin.netdns_queryends withemergate.netdns_queryends withethereumpool.codns_queryends witheu.luckpool.netdns_queryends witheu.minerpool.pwdns_queryends withextremehash.comdns_queryends withextremepool.orgdns_queryends withextrmepool.orgdns_queryends withfairhash.orgdns_queryends withfairpool.clouddns_queryends withfairpool.xyzdns_queryends withfcn-xmr.pool.minergate.comdns_queryends withfee.xmrig.comdns_queryends withfr.minexmr.comdns_queryends withfreeyy.medns_queryends withgntl.co.ukdns_queryends withhash-to-coins.comdns_queryends withhashanywhere.comdns_queryends withhashfor.cashdns_queryends withhashinvest.netdns_queryends withhashinvest.wsdns_queryends withhashvault.prodns_queryends withhellominer.comdns_queryends withherominers.comdns_queryends withhuadong1-aeon.ppxxmr.comdns_queryends withiwanttoearn.moneydns_queryends withjw-js1.ppxxmr.comdns_queryends withkippo.eudns_queryends withkoto-pool.workdns_queryends withlhr.nbminer.comdns_queryends withlhr3.nbminer.comdns_queryends withlinux-repository-updates.comdns_queryends withlinux.monerpool.orgdns_queryends withlitecoinpool.orgdns_queryends withlokiturtle.herominers.comdns_queryends withluckpool.netdns_queryends withmasari.miner.rocksdns_queryends withmine.c3pool.comdns_queryends withmine.moneropool.comdns_queryends withmine.ppxxmr.comdns_queryends withmine.zpool.cadns_queryends withmine1.ppxxmr.comdns_queryends withminemonero.gqdns_queryends withminer.centerdns_queryends withminer.ppxxmr.comdns_queryends withminer.rocksdns_queryends withminercircle.comdns_queryends withminergate.comdns_queryends withminerpool.pwdns_queryends withminerrocks.comdns_queryends withminers.prodns_queryends withminerxmr.rudns_queryends withmineshaft.mldns_queryends withminexmr.cndns_queryends withminexmr.comdns_queryends withminexmr.orgdns_queryends withmining-help.rudns_queryends withmininglottery.eudns_queryends withminingpoolhub.comdns_queryends withmixpools.orgdns_queryends withmoner.monerpool.orgdns_queryends withmoner1min.monerpool.orgdns_queryends withmonero-master.crypto-pool.frdns_queryends withmonero.crypto-pool.frdns_queryends withmonero.farmdns_queryends withmonero.hashvault.prodns_queryends withmonero.herominers.comdns_queryends withmonero.lindon-pool.windns_queryends withmonero.miners.prodns_queryends withmonero.netdns_queryends withmonero.riefly.iddns_queryends withmonero.us.todns_queryends withmonerocean.streamdns_queryends withmonerogb.comdns_queryends withmonerohash.comdns_queryends withmonerominers.netdns_queryends withmoneroocean.streamdns_queryends withmoneropool.comdns_queryends withmoneropool.nldns_queryends withmoneropool.rudns_queryends withmoneropools.comdns_queryends withmonerorx.comdns_queryends withmonerpool.orgdns_queryends withmooo.comdns_queryends withmoriaxmr.comdns_queryends withmro.pool.minergate.comdns_queryends withmultipool.usdns_queryends withmultipooler.comdns_queryends withmyxmr.pwdns_queryends withna.luckpool.netdns_queryends withnanopool.orgdns_queryends withnbminer.comdns_queryends withnode3.luckpool.netdns_queryends withnoobxmr.comdns_queryends withpangolinminer.comgandalph3000.comdns_queryends withpool-proxy.comdns_queryends withpool.4i7i.comdns_queryends withpool.armornetwork.orgdns_queryends withpool.cortins.tkdns_queryends withpool.gntl.co.ukdns_queryends withpool.hashvault.prodns_queryends withpool.minergate.comdns_queryends withpool.minexmr.comdns_queryends withpool.monero.hashvault.prodns_queryends withpool.ppxxmr.comdns_queryends withpool.somec.ccdns_queryends withpool.supportdns_queryends withpool.supportxmr.comdns_queryends withpool.usa-138.comdns_queryends withpool.xmr.ptdns_queryends withpool.xmrfast.comdns_queryends withpool2.armornetwork.orgdns_queryends withpoolchange.ppxxmr.comdns_queryends withpooldd.comdns_queryends withpoolmining.orgdns_queryends withpoolto.bedns_queryends withppxvip1.ppxxmr.comdns_queryends withppxxmr.comdns_queryends withprohash.netdns_queryends withr.twotouchauthentication.onlinedns_queryends withrandomx.xmrig.comdns_queryends withratchetmining.comdns_queryends withsecumine.netdns_queryends withseed.emercoin.comdns_queryends withseed.emercoin.netdns_queryends withseed.emergate.netdns_queryends withseed1.joulecoin.orgdns_queryends withseed2.joulecoin.orgdns_queryends withseed3.joulecoin.orgdns_queryends withseed4.joulecoin.orgdns_queryends withseed5.joulecoin.orgdns_queryends withseed6.joulecoin.orgdns_queryends withseed7.joulecoin.orgdns_queryends withseed8.joulecoin.orgdns_queryends withsemipool.comdns_queryends withsg.minexmr.comdns_queryends withsheepman.mine.bzdns_queryends withshscrypto.netdns_queryends withsiamining.comdns_queryends withsumokoin.minerrocks.comdns_queryends withsupportxmr.comdns_queryends withsuprnova.ccdns_queryends withteracycle.netdns_queryends withtrtl.cnpool.ccdns_queryends withtrtl.pool.mine2gether.comdns_queryends withtubepool.xyzdns_queryends withturtle.miner.rocksdns_queryends withunipool.prodns_queryends withus-west.minexmr.comdns_queryends withusxmrpool.comdns_queryends withviaxmr.comdns_queryends withwalpool.comdns_queryends withwebcoin.medns_queryends withwebservicepag.webhop.netdns_queryends withxiazai.monerpool.orgdns_queryends withxiazai1.monerpool.orgdns_queryends withxmc.pool.minergate.comdns_queryends withxmo.pool.minergate.comdns_queryends withxmr-asia1.nanopool.orgdns_queryends withxmr-au1.nanopool.orgdns_queryends withxmr-eu1.nanopool.orgdns_queryends withxmr-eu2.nanopool.orgdns_queryends withxmr-jp1.nanopool.orgdns_queryends withxmr-us-east1.nanopool.orgdns_queryends withxmr-us-west1.nanopool.orgdns_queryends withxmr-us.suprnova.ccdns_queryends withxmr-usa.dwarfpool.comdns_queryends withxmr.2miners.comdns_queryends withxmr.5b6b7b.rudns_queryends withxmr.alimabi.cndns_queryends withxmr.bohemianpool.comdns_queryends withxmr.crypto-pool.frdns_queryends withxmr.crypto-pool.infodns_queryends withxmr.f2pool.comdns_queryends withxmr.hashcity.orgdns_queryends withxmr.hex7e4.rudns_queryends withxmr.ip28.netdns_queryends withxmr.monerpool.orgdns_queryends withxmr.mypool.onlinedns_queryends withxmr.nanopool.orgdns_queryends withxmr.pool.gntl.co.ukdns_queryends withxmr.pool.minergate.comdns_queryends withxmr.poolto.bedns_queryends withxmr.ppxxmr.comdns_queryends withxmr.prohash.netdns_queryends withxmr.ptdns_queryends withxmr.simka.pwdns_queryends withxmr.somec.ccdns_queryends withxmr.suprnova.ccdns_queryends withxmr.usa-138.comdns_queryends withxmr.vip.pool.minergate.comdns_queryends withxmr1min.monerpool.orgdns_queryends withxmrf.520fjh.orgdns_queryends withxmrf.fjhan.clubdns_queryends withxmrfast.comdns_queryends withxmrget.comdns_queryends withxmrigcc.graef.indns_queryends withxmrminer.ccdns_queryends withxmrminerpro.comdns_queryends withxmrpool.comdns_queryends withxmrpool.dedns_queryends withxmrpool.eudns_queryends withxmrpool.medns_queryends withxmrpool.netdns_queryends withxmrpool.xyzdns_queryends withxx11m.monerpool.orgdns_queryends withxx11mv2.monerpool.orgdns_queryends withxxx.hex7e4.rudns_queryends withzarabotaibitok.rudns_queryends withzer0day.ru
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
dns_query | ends_with |
| field:"dns_query" kind:ends_with |
dns_query | is_not_null | field:"dns_query" kind:is_not_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
source_ip |
source_port |
dns_query |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"account_id": "0123456789",
"answers": {
"Class": "IN",
"Rdata": "1.2.3.4",
"Type": "A"
},
"p_log_type": "AWS.VPCDns",
"query_class": "IN",
"query_name": "moneropool.ru",
"query_timestamp": "2022-06-25 00:27:53",
"query_type": "A",
"rcode": "NOERROR",
"region": "us-west-2",
"srcaddr": "5.6.7.8",
"srcids": {
"instance": "i-0abc234"
},
"srcport": "8888",
"transport": "UDP",
"version": "1.100000",
"vpc_id": "vpc-abc123"
}
AWS DNS Logs Deleted
#Detects when logs for a DNS Resolver have been removed.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event) and event.get("eventName") == "DeleteResolverQueryLogConfig"
)
def title(event):
account = event.deep_get("userIdentity", "accountId", default="<UNKNOWN ACCOUNT>")
region = event.get("awsRegion", "<UNKNOWN REGION>")
return f"DNS logs have been deleted in {account} in {region}"
def alert_context(event):
log_id = event.deep_get("requestParameters", "resolverQueryLogConfigId", "<UNKNOWN LOG ID>")
return aws_rule_context(event) | {"logId": log_id}
Rule specification
AnalysisType: rule
Filename: aws_dns_logs_deleted.py
RuleID: "AWS.CloudTrail.DNSLogsDeleted"
DisplayName: "AWS DNS Logs Deleted"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
Reports:
MITRE ATT&CK:
- TA0005:T1562.008 # Defense Evasion: Disable or Modify Cloud Logs
Stratus Red Team:
- aws.defense-evasion.dns-delete-logs
Description: "Detects when logs for a DNS Resolver have been removed."
Reference:
https://stratus-red-team.cloud/attack-techniques/AWS/aws.defense-evasion.dns-delete-logs/
Runbook: Determine if the log removal to is legitimate.
Tags:
- AWS
- Cloudtrail
- Defense Evasion
- Impair Defenses
- Disable or Modify Cloud Logs
- Defense Evasion:Impair Defenses
- Security Control
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisDeleteResolverQueryLogConfig
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"DeleteResolverQueryLogConfig" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
accountId | userIdentity.accountId |
awsRegion |
Response runbook
Determine if the log removal to is legitimate.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "27e6be30-7c86-4544-b0e0-a60b0c927887",
"eventName": "DeleteResolverQueryLogConfig",
"eventSource": "route53resolver.amazonaws.com",
"eventTime": "2024-11-27 18:18:58.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_event_time": "2024-11-27 18:18:58.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2024-11-27 18:25:54.213480847",
"readOnly": false,
"recipientAccountId": "111122223333",
"requestID": "a45a0f04-8911-4c95-a9d7-3fead8a9bc45",
"requestParameters": {
"originSequenceNumber": 0,
"resolverQueryLogConfigId": "rqlc-5aa596fe3bd84ec6"
},
"responseElements": {
"resolverQueryLogConfig": {
"arn": "arn:aws:route53resolver:us-west-2:111122223333:resolver-query-log-config/rqlc-5aa596fe3bd84ec6",
"associationCount": 0,
"creationTime": "2024-11-27T18:18:56.881520365Z",
"creatorRequestId": "tf-r53-resolver-query-log-config-20241127181856499800000001",
"destinationArn": "arn:aws:s3:::sample-bucket-name",
"id": "rqlc-5aa596fe3bd84ec6",
"name": "sample-config-name",
"ownerId": "111122223333",
"shareStatus": "NOT_SHARED",
"status": "DELETING"
}
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "route53resolver.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "stratus-red-team_dbac929e-ae11-4539-8753-35dbcbbc3256",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/leroy.jenkins",
"principalId": "SAMPLE_PRINCIPAL_ID:leroy.jenkins",
"sessionContext": {
"attributes": {
"creationDate": "2024-11-27T18:17:21Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS DynamoDB Table Autoscaling
#DynamoDB Auto Scaling can dynamically adjust provisioned throughput capacity in response to traffic patterns. This enables a table to increase its provisioned read and write capacity to handle sudden increases in traffic
Detection logic
from panther_base_helpers import deep_get
# If you do not wish to enforce application auto-scaling on your dynamo tables Global
# Secondary Indices, set this variable to False
CHECK_GSI = True
def policy(resource):
# Check if this table has never had auto scaling configured
if resource["BillingModeSummary"] is None and resource["AutoScalingDescriptions"] is None:
return False
# Check if this table is not on provisioned billing (and therefore auto scaling does not apply)
if deep_get(resource, "BillingModeSummary", "BillingMode") != "PROVISIONED":
return True
# Check if application auto scaling is configured
if resource["AutoScalingDescriptions"] is None:
return False
# Build a list of all the resources (the table and optionally the GSI's) to be checked
table_id = "table/" + resource["Name"]
resource_auto_scaling = {table_id: False}
if CHECK_GSI:
# We cannot use resource.get('GSI', []) here as the value is present, it is just a NoneType
for gsi in resource["GlobalSecondaryIndexes"] or []:
resource_auto_scaling[table_id + "/index/" + gsi["IndexName"]] = False
# Check that each resource that requires application autoscaling has it enabled
for auto_scale_target in resource["AutoScalingDescriptions"]:
resource_auto_scaling[auto_scale_target["ResourceId"]] = True
# Return True if all resources have autoscaling enabled
return all(resource_auto_scaling.values())
Rule specification
AnalysisType: policy
Filename: aws_dynamodb_autoscaling.py
PolicyID: "AWS.DynamoDB.Autoscaling"
DisplayName: "AWS DynamoDB Table Autoscaling"
Enabled: true
ResourceTypes:
- AWS.DynamoDB.Table
Tags:
- AWS
- Availability
Severity: Low
Description: >
DynamoDB Auto Scaling can dynamically adjust provisioned throughput capacity in response to
traffic patterns. This enables a table to increase its provisioned read and write capacity
to handle sudden increases in traffic
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-dynamodb-table-has-autoscaling-enabled
Reference: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/AutoScaling.html
Stages and Predicates
Flags AWS.DynamoDB.Table resources when any of the conditions below holds.
Condition
any of:
all of:
BillingModeSummaryis emptyAutoScalingDescriptionsis empty
all of:
BillingModeSummary.BillingModeisPROVISIONEDAutoScalingDescriptionsis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
AutoScalingDescriptions | is_not_null | excludes:AutoScalingDescriptions | |
BillingModeSummary.BillingMode | ne | PROVISIONED | excludes:BillingModeSummary.BillingMode field:"BillingModeSummary.BillingMode" value:"PROVISIONED" |
AutoScalingDescriptions | is_null | excludes:AutoScalingDescriptions | |
BillingModeSummary | is_null | excludes:BillingModeSummary |
Indicators
These rows show field, operator, and value matches.
Response runbook
AWS DynamoDB Table Autoscaling Configuration
#DynamoDB Auto Scaling can dynamically adjust provisioned throughput capacity in response to traffic patterns. This enables a table to increase its provisioned read and write capacity to handle sudden increases in traffic
Detection logic
from panther_base_helpers import deep_get
# If you do not wish to enforce application auto-scaling on your dynamo tables'
# Global Secondary Indices, set this variable to False
CHECK_GSI = True
READ_CAP = {
"MIN": 5,
"MAX": 15,
"TYPE": "READ",
}
WRITE_CAP = {
"MIN": 5,
"MAX": 50000,
"TYPE": "WRITE",
}
def policy(resource):
# Check if this table has never had auto scaling configured
if resource["BillingModeSummary"] is None and resource["AutoScalingDescriptions"] is None:
return False
# Check if this table is not on provisioned billing (and therefore auto scaling does not apply)
if deep_get(resource, "BillingModeSummary", "BillingMode") != "PROVISIONED":
return True
# Check if application auto scaling is configured at all
if resource["AutoScalingDescriptions"] is None:
return False
# Build a list of all the resources (the table and optionally the GSI's) to be checked
table_id = "table/" + resource["Name"]
resource_auto_scaling = {
table_id + "/READ": False,
table_id + "/WRITE": False,
}
if CHECK_GSI:
# We cannot use resource.get('GSI', []) here as the value is present, it is just a NoneType
for gsi in resource["GlobalSecondaryIndexes"] or []:
resource_auto_scaling[table_id + "/index/" + gsi["IndexName"] + "/READ"] = False
resource_auto_scaling[table_id + "/index/" + gsi["IndexName"] + "/WRITE"] = False
# Check that each resource that requires application autoscaling has it enabled
for auto_scale_target in resource["AutoScalingDescriptions"]:
# Determine if this is a target for reading capacity or writing capacity
cap = (
WRITE_CAP
if "WriteCapacityUnits" in auto_scale_target["ScalableDimension"]
else READ_CAP
)
# Verify that the minimum and maximum scalable targets are within the configured bounds
resource_auto_scaling[auto_scale_target["ResourceId"] + "/" + cap["TYPE"]] = (
auto_scale_target["MinCapacity"] > cap["MIN"]
and auto_scale_target["MaxCapacity"] < cap["MAX"]
)
# Verify that each scalable target was within configured bounds
return all(resource_auto_scaling.values())
Rule specification
AnalysisType: policy
Filename: aws_dynamodb_autoscaling_configuration.py
PolicyID: "AWS.DynamoDB.AutoscalingConfiguration"
DisplayName: "AWS DynamoDB Table Autoscaling Configuration"
Enabled: false
ResourceTypes:
- AWS.DynamoDB.Table
Tags:
- AWS
- Availability
- Configuration Required
Severity: Low
Description: >
DynamoDB Auto Scaling can dynamically adjust provisioned throughput capacity in response to
traffic patterns. This enables a table to increase its provisioned read and write capacity
to handle sudden increases in traffic
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-dynamodb-table-has-autoscaling-targets-configured
Reference: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/AutoScaling.html
Stages and Predicates
Flags AWS.DynamoDB.Table resources when any of the conditions below holds.
Condition
any of:
all of:
BillingModeSummaryis emptyAutoScalingDescriptionsis empty
all of:
BillingModeSummary.BillingModeisPROVISIONEDAutoScalingDescriptionsis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
AutoScalingDescriptions | is_not_null | excludes:AutoScalingDescriptions | |
BillingModeSummary.BillingMode | ne | PROVISIONED | excludes:BillingModeSummary.BillingMode field:"BillingModeSummary.BillingMode" value:"PROVISIONED" |
AutoScalingDescriptions | is_null | excludes:AutoScalingDescriptions | |
BillingModeSummary | is_null | excludes:BillingModeSummary |
Indicators
These rows show field, operator, and value matches.
Response runbook
AWS DynamoDB Table TTL
#This policy validates that all DynamoDB tables have a TTL field configured.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
if not resource["TimeToLiveDescription"]:
return False
return deep_get(resource, "TimeToLiveDescription", "TimeToLiveStatus") == "ENABLED"
Rule specification
AnalysisType: policy
Filename: aws_dynamodb_table_ttl_enabled.py
PolicyID: "AWS.DynamoDB.TableTTLEnabled"
DisplayName: "AWS DynamoDB Table TTL"
Enabled: false
ResourceTypes:
- AWS.DynamoDB.Table
Tags:
- AWS
- Database
- PCI
- Collection:Data From Cloud Storage Object
Reports:
PCI:
- 3.1
MITRE ATT&CK:
- TA0009:T1530
Severity: Low
Description: >
This policy validates that all DynamoDB tables have a TTL field configured.
Runbook: >
Enable table TTL.
Reference: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html
Stages and Predicates
Flags AWS.DynamoDB.Table resources when any of the conditions below holds.
Condition
any of:
TimeToLiveDescriptionis emptyTimeToLiveDescription.TimeToLiveStatusis notENABLED
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
TimeToLiveDescription | is_not_null | excludes:TimeToLiveDescription | |
TimeToLiveDescription.TimeToLiveStatus | eq | ENABLED | excludes:TimeToLiveDescription.TimeToLiveStatus field:"TimeToLiveDescription.TimeToLiveStatus" value:"ENABLED" |
Indicators
These rows show field, operator, and value matches.
Response runbook
Enable table TTL.
AWS EC2 AMI Approved Host
#Checks that AWS EC2 AMI's are only launched on approved dedicated hosts.
Detection logic
from panther_base_helpers import deep_get
# APPROVED_HOSTS maps AMI IDs to a list of approved dedicated hosts for that AMI.
APPROVED_HOSTS = {
"EXAMPLE-AMI-ID": ["EXAMPLE-HOST-ID"],
}
def policy(resource):
# Check if this Instance's AMI is restricted to certain hosts
if resource.get("ImageId") not in APPROVED_HOSTS:
return True
return deep_get(resource, "Placement", "HostId") in APPROVED_HOSTS[resource.get("ImageId")]
Rule specification
AnalysisType: policy
Filename: aws_ec2_ami_approved_host.py
PolicyID: "AWS.EC2.AMI.ApprovedHost"
DisplayName: "AWS EC2 AMI Approved Host"
Enabled: false
ResourceTypes:
- AWS.EC2.Instance
Tags:
- AWS
- Configuration Required
Severity: Low
Description: >
Checks that AWS EC2 AMI's are only launched on approved dedicated hosts.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-ami-launched-on-approved-host
Reference: https://aws.amazon.com/ec2/dedicated-hosts/
Stages and Predicates
Flags AWS.EC2.Instance resources when the condition below holds.
Condition
ImageIdis one ofEXAMPLE-AMI-ID
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
ImageId | eq | EXAMPLE-AMI-ID | excludes:ImageId field:"ImageId" value:"EXAMPLE-AMI-ID" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
ImageId | in |
| field:"ImageId" kind:in value:"EXAMPLE-AMI-ID" |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-ami-launched-on-approved-host
AWS EC2 AMI Approved Instance Type
#This policy ensures that the EC2 instance is running with an instance type approved for its AMI.
Detection logic
# APPROVED_TYPES maps AMI IDs to a list of approved types for that AMI.
APPROVED_TYPES = {
"EXAMPLE-AMI-ID": ["t2.small"],
}
def policy(resource):
# Check if this Instance's AMI is restricted to certain instance types
if resource["ImageId"] not in APPROVED_TYPES:
return True
return resource["InstanceType"] in APPROVED_TYPES[resource["ImageId"]]
Rule specification
AnalysisType: policy
Filename: aws_ec2_ami_approved_instance_type.py
PolicyID: "AWS.EC2.AMI.ApprovedInstanceType"
DisplayName: "AWS EC2 AMI Approved Instance Type"
Enabled: false
ResourceTypes:
- AWS.EC2.Instance
Tags:
- AWS
- Configuration Required
Severity: Low
Description: >
This policy ensures that the EC2 instance is running with an instance type approved for its AMI.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-ami-launched-on-approved-instance-type
Reference: https://aws.amazon.com/ec2/instance-types/
Stages and Predicates
Flags AWS.EC2.Instance resources when the condition below holds.
Condition
ImageIdis one ofEXAMPLE-AMI-ID
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
ImageId | eq | EXAMPLE-AMI-ID | excludes:ImageId field:"ImageId" value:"EXAMPLE-AMI-ID" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
ImageId | in |
| field:"ImageId" kind:in value:"EXAMPLE-AMI-ID" |
Response runbook
AWS EC2 AMI Approved Tenancy
#This policy ensures that the EC2 instance was launched with a tenancy approved for its AMI.
Detection logic
from panther_base_helpers import deep_get
# APPROVED_TENANCIES maps AMI IDs to a list of approved tenancy states for that AMI.
# The possible tenancy states are dedicated, host, and default
APPROVED_TENANCIES = {
"EXAMPLE-AMI-ID": ["default"],
}
def policy(resource):
# Check if this Instance's AMI has a required tenancy setting
if resource["ImageId"] not in APPROVED_TENANCIES:
return True
return deep_get(resource, "Placement", "Tenancy") in APPROVED_TENANCIES[resource["ImageId"]]
Rule specification
AnalysisType: policy
Filename: aws_ec2_ami_approved_tenancy.py
PolicyID: "AWS.EC2.AMI.ApprovedTenancy"
DisplayName: "AWS EC2 AMI Approved Tenancy"
Enabled: false
ResourceTypes:
- AWS.EC2.Instance
Tags:
- AWS
- Configuration Required
Severity: Low
Description: >
This policy ensures that the EC2 instance was launched with a tenancy approved for its AMI.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-ami-launched-with-approved-tenancy
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/dedicated-instance.html
Stages and Predicates
Flags AWS.EC2.Instance resources when the condition below holds.
Condition
ImageIdis one ofEXAMPLE-AMI-ID
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
ImageId | eq | EXAMPLE-AMI-ID | excludes:ImageId field:"ImageId" value:"EXAMPLE-AMI-ID" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
ImageId | in |
| field:"ImageId" kind:in value:"EXAMPLE-AMI-ID" |
Response runbook
AWS EC2 Download Instance User Data
#An entity has accessed the user data scripts of multiple EC2 instances.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_regions, aws_rule_context
from panther_core import PantherEvent
def rule(event: PantherEvent) -> bool:
# Panther's Cloud Security Scanning feature triggers false positives
actor = event.udm("actor_user")
if (
any(actor == "PantherAuditRole-" + region for region in aws_regions())
and event.get("userAgent") == event.get("sourceIPAddress") == "cloudformation.amazonaws.com"
):
return False
return (
event.get("eventName") == "DescribeInstanceAttribute"
and event.deep_get("requestParameters", "attribute") == "userData"
)
def title(event: PantherEvent) -> str:
account_id = event.get("recipientAccountId", "UNKNOWN AWS ACCOUNT")
actor_user = event.udm("actor_user")
return (
f"EC2 Instance User Data accessed in bulk by [{actor_user}] "
f"in AWS Account [{account_id}]"
)
def severity(event: PantherEvent) -> str:
if not aws_cloudtrail_success(event):
return "LOW"
return "DEFAULT"
def alert_context(event: PantherEvent) -> dict:
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ec2_download_instance_user_data.py
RuleID: "AWS.EC2.DownloadInstanceUserData"
DisplayName: AWS EC2 Download Instance User Data
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0007:T1580 # Discovery: Cloud Infrastructure Discovery
Stratus Red Team:
- aws.discovery.ec2-download-user-data
Description: >
An entity has accessed the user data scripts of multiple EC2 instances.
Threshold: 10
DedupPeriodMinutes: 1440
Reference: >
https://hackingthe.cloud/aws/general-knowledge/introduction_user_data/
Runbook: |
An entity has accessed the user data scripts of multiple EC2 instances. This is
often an attempt to find unsecured credentials. Ensure the EC2 instances accessed
do not have any sensitive information stored in the user data.
Cloud security scanning tools may trigger false positives. Add an exclude filter for
the scanning tool's service account to prevent false positives.
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_instance_ids
Tags:
- AWS CloudTrail
- EC2
- Discovery
- Cloud Infrastructure Discovery
- Discovery - Cloud Infrastructure Discovery
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisDescribeInstanceAttributerequestParameters.attributeisuserData
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"DescribeInstanceAttribute" |
requestParameters.attribute | eq |
| field:"requestParameters.attribute" kind:eq value:"userData" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
An entity has accessed the user data scripts of multiple EC2 instances. This is
often an attempt to find unsecured credentials. Ensure the EC2 instances accessed
do not have any sensitive information stored in the user data.
Cloud security scanning tools may trigger false positives. Add an exclude filter for
the scanning tool's service account to prevent false positives.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"errorCode": "Client.UnauthorizedOperation",
"errorMessage": "You are not authorized to perform this operation. User: arn:aws:sts::111122223333:assumed-role/SampleRole/sample-session is not authorized to perform: ec2:DescribeInstanceAttribute on resource: arn:aws:ec2:us-west-2:111122223333:instance/* because no identity-based policy allows the ec2:DescribeInstanceAttribute action. Encoded authorization failure message: 2RCBDBB0PcDpowyBFV7UpJobr-4iGb1MWqjKES--SAO_ca79pxBjKLQQ-sBGm2_m8jjcTAITBlNRa1WCVabq3VCghw9mbj0XZ3-2ZfsVyUrQWiTSUqYh9CUFVnWOmp710QzzKLj-Dy5Aup-bkSCbadoQe6HMevGPiSGeklgwBQc4jLO3oIbPTsujAijnaVr4CUIRBkD4KI8H2ZshEgJOujlpkg47FYqU8bVPNEIv9OwsQ9g4dRMB_cJ7C43kpURWIuiWkFpN8Q84RubskMNB3IfkHGR_Y63Z3LLzOCbPgT4S2zt97PBryCs-lUWSdA2ZCq9dcOtoHQq3Ed-3eB4o7FdZ0cJawa2i6oeUGzzPC2JtBQBcCqExMS8CXENosA0LY9cRUOSo4xc439wzlNpUbMI6K_y1AZWWN0f1QzLW1GkhtlUaLZFvLTwMqTVhClznln2ntmAJ_6iVVlvYco5x6Z4oNn8pMVPk76Iq8yM9o_2-tXmt0st7y_9B83eTSFOTbRnwgHabQXuMyzFKAA3xtuMcbcpB-Ij8c70lkv-eVQeJnfqRd_zbbnsMhxBybfjQyirKjY8bfLg",
"eventCategory": "Management",
"eventID": "fa67e0b9-9837-4c84-baa0-78a271821d3e",
"eventName": "DescribeInstanceAttribute",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2024-12-11 21:49:07.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.10",
"managementEvent": true,
"p_event_time": "2024-12-11 21:49:07.000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2024-12-11 21:55:54.289594",
"readOnly": true,
"recipientAccountId": "111122223333",
"requestID": "f2362cc7-4396-4be5-950c-16c36dddde76",
"requestParameters": {
"attribute": "userData",
"instanceId": "i-12345678"
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "ec2.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "sample-user-agent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/sample-session",
"principalId": "SAMPLE_PRINCIPAL_ID:sample-session",
"sessionContext": {
"attributes": {
"creationDate": "2024-12-11T21:49:04Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS EC2 EBS Encryption Disabled
#Identifies disabling of default EBS encryption. Disabling default encryption does not change the encryption status of existing volumes.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS EC2 Disable EBS Encryption (Sigma)
- AWS EC2 Encryption Disabled (Elastic)
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return (
event.get("eventSource") == "ec2.amazonaws.com"
and event.get("eventName") == "DisableEbsEncryptionByDefault"
)
def title(event):
return (
"EC2 EBS Default Encryption was disabled in "
f"[{event.get('recipientAccountId')}] - "
f"[{event.get('awsRegion')}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: "Identifies disabling of default EBS encryption. Disabling default encryption does not change the encryption status of existing volumes. "
DisplayName: "AWS EC2 EBS Encryption Disabled"
Enabled: true
Filename: aws_ec2_ebs_encryption_disabled.py
Reports:
MITRE ATT&CK:
- TA0040:T1486
- TA0040:T1565
Runbook: Verify this action was intended and if any EBS volumes were created after the change.
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html#encryption-by-default
Severity: Medium
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.EC2.EBS.Encryption.Disabled"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisec2.amazonaws.comeventNameisDisableEbsEncryptionByDefault
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"DisableEbsEncryptionByDefault" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"ec2.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Verify this action was intended and if any EBS volumes were created after the change.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventName": "DisableEbsEncryptionByDefault",
"eventSource": "ec2.amazonaws.com",
"recipientAccountId": "123456789",
"sourceIPAddress": "1.2.3.4",
"userAgent": "Chrome Browser"
}
AWS EC2 Image Monitoring
#Checks CloudTrail for occurrences of EC2 Image Actions.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Execution |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS EC2 Export Task (Elastic)
Detection logic
# AWS CloudTrail API eventNames for EC2 Image Actions
EC2_IMAGE_ACTIONS = [
"CopyFpgaImage",
"CopyImage",
"CreateFpgaImage",
"CreateImage",
"CreateRestoreImageTask",
"CreateStoreImageTask",
"ImportImage",
]
def rule(event):
# Disqualify any eventSource that is not ec2
if event.get("eventSource", "") != "ec2.amazonaws.com":
return False
# Disqualify AWS Service-Service operations, which can appear in a variety of forms
if (
# FYI there is a weird quirk in the sourceIPAddress field of CloudTrail
# events with ec2.amazonaws.com as the source name where users of the
# web-console will have their sourceIPAddress recorded as "AWS Internal"
# though their userIdentity will be more normal.
# Example cloudtrail event in the "Terminate instance From WebUI with assumedRole" test
event.get("sourceIPAddress", "").endswith(".amazonaws.com")
or event.deep_get("userIdentity", "type", default="") == "AWSService"
or event.deep_get("userIdentity", "invokedBy", default="") == "AWS Internal"
or event.deep_get("userIdentity", "invokedBy", default="").endswith(".amazonaws.com")
):
return False
# Dry run operations get logged as SES Internal in the sourceIPAddress
# but not in the invokedBy field
if event.get("errorCode", "") == "Client.DryRunOperation":
return False
# Disqualify any eventNames that do not Include Image Actions
# and events that have readOnly set to false
if event.get("eventName", "") in EC2_IMAGE_ACTIONS:
return True
return False
def title(event):
return (
f"[{event.deep_get('userIdentity', 'sessionContext', 'sessionIssuer', 'userName')}] "
f"triggered a CloudTrail action [{event.get('eventName')}] "
f"within AWS Account ID: [{event.get('recipientAccountId')}]"
)
Rule specification
AnalysisType: rule
Description: Checks CloudTrail for occurrences of EC2 Image Actions.
DisplayName: "AWS EC2 Image Monitoring"
Enabled: true
Filename: aws_ec2_monitoring.py
Reports:
MITRE ATT&CK:
- TA0002:T1204
Runbook: Verify that the action was not taken by a malicious actor.
Reference: https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonec2imagebuilder.html#amazonec2imagebuilder-actions-as-permissions
Severity: Info
Tags:
- ec2
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.EC2.Monitoring"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisec2.amazonaws.comsourceIPAddressdoes not end with.amazonaws.comuserIdentity.typeis notAWSServiceuserIdentity.invokedByis notAWS InternaluserIdentity.invokedBydoes not end with.amazonaws.comerrorCodeis notClient.DryRunOperationeventNameis one ofCopyFpgaImage,CopyImage,CreateFpgaImage,CreateImage,CreateRestoreImageTask
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
sourceIPAddress | ends_with | .amazonaws.com | excludes:sourceIPAddress field:"sourceIPAddress" value:".amazonaws.com" |
userIdentity.invokedBy | ends_with | .amazonaws.com | excludes:userIdentity.invokedBy field:"userIdentity.invokedBy" value:".amazonaws.com" |
userIdentity.invokedBy | eq | AWS Internal | excludes:userIdentity.invokedBy field:"userIdentity.invokedBy" value:"AWS Internal" |
userIdentity.type | eq | AWSService | excludes:userIdentity.type field:"userIdentity.type" value:"AWSService" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | ne |
| field:"aws::errorCode" kind:ne value:"Client.DryRunOperation" |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"ec2.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
userName | userIdentity.sessionContext.sessionIssuer.userName |
eventName | |
recipientAccountId |
Response runbook
Verify that the action was not taken by a malicious actor.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "0ea3f05a-066c-43f9-8869-393ba67e7936",
"eventName": "CreateImage",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2022-09-29 22:25:17",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_any_aws_account_ids": [
"123456789101"
],
"p_any_aws_arns": [
"arn:aws:iam::123456789101:role/DevAdministrator",
"arn:aws:sts::123456789101:assumed-role/DevAdministrator/test_user"
],
"p_any_aws_instance_ids": [
"i-0381a3817f72a949d"
],
"p_any_domain_names": [
"AWS Internal"
],
"p_any_trace_ids": [
"ASIA5PZQZ5QHE2FUNXHR"
],
"p_event_time": "2022-09-29 22:25:17",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2022-09-29 22:27:25.748",
"p_row_id": "66011977ec1fd0cf9dacf7d913f08d06",
"p_source_id": "125a8146-e3ea-454b-aed7-9e08e735b670",
"p_source_label": "CloudTrail Logs",
"readOnly": false,
"recipientAccountId": "123456789101",
"requestID": "e686939a-a08a-4fd6-abf5-9ea34793cf25",
"requestParameters": {
"blockDeviceMapping": {
"items": [
{
"deviceName": "/dev/xvda",
"ebs": {
"deleteOnTermination": true,
"volumeSize": 8
}
}
]
},
"instanceId": "i-0381a3817f72a949d",
"name": "testimage",
"noReboot": false
},
"responseElements": {
"imageId": "ami-06aaf5e4b77161786",
"requestId": "e686939a-a08a-4fd6-abf5-9ea34793cf25"
},
"sessionCredentialFromConsole": true,
"sourceIPAddress": "AWS Internal",
"userAgent": "AWS Internal",
"userIdentity": {
"accessKeyId": "ASIA5PZQZ5QHE2FUNXHR",
"accountId": "123456789101",
"arn": "arn:aws:sts::123456789101:assumed-role/DevAdministrator/test_user",
"principalId": "AROA5PZQZ5QHBULW27VAC:test_user",
"sessionContext": {
"attributes": {
"creationDate": "2022-09-29T22:22:46Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789101",
"arn": "arn:aws:iam::123456789101:role/DevAdministrator",
"principalId": "AROA5PZQZ5QHBULW27VAC",
"type": "Role",
"userName": "DevAdministrator"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS EC2 Instance Approved AMI
#This policy ensures the given EC2 instance is running an AMI from the approved list of AMI's.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
# Tags: ['AWS Managed Rules - Compute']
APPROVED_AMIS = {
"EXAMPLE-AMI-ID",
}
def policy(resource):
return resource["ImageId"] in APPROVED_AMIS
Rule specification
AnalysisType: policy
Filename: aws_ec2_instance_approved_ami.py
PolicyID: "AWS.EC2.Instance.ApprovedAMI"
DisplayName: "AWS EC2 Instance Approved AMI"
Enabled: false
ResourceTypes:
- AWS.EC2.Instance
Tags:
- AWS
- Configuration Required
- Impact:Resource Hijacking
Reports:
MITRE ATT&CK:
- TA0040:T1496
Severity: High
Description: >
This policy ensures the given EC2 instance is running an AMI from the approved list of AMI's.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-running-on-approved-ami
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AMIs.html
Stages and Predicates
Flags AWS.EC2.Instance resources when the condition below holds.
Condition
ImageIdis not one ofEXAMPLE-AMI-ID
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
ImageId | eq | EXAMPLE-AMI-ID | excludes:ImageId field:"ImageId" value:"EXAMPLE-AMI-ID" |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-running-on-approved-ami
AWS EC2 Instance Approved Host
#This policy ensures the given EC2 Instance is running on an approved dedicated host.
Detection logic
from panther_base_helpers import deep_get
# Tags: ['AWS Managed Rules - Compute']
APPROVED_HOSTS = {
"EXAMPLE-HOST-ID",
}
def policy(resource):
return deep_get(resource, "Placement", "HostId") in APPROVED_HOSTS
Rule specification
AnalysisType: policy
Filename: aws_ec2_instance_approved_host.py
PolicyID: "AWS.EC2.Instance.ApprovedHost"
DisplayName: "AWS EC2 Instance Approved Host"
Enabled: false
ResourceTypes:
- AWS.EC2.Instance
Tags:
- AWS
- Configuration Required
Severity: Low
Description: >
This policy ensures the given EC2 Instance is running on an approved dedicated host.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-running-on-approved-host
Reference: https://aws.amazon.com/ec2/dedicated-hosts/
Stages and Predicates
Flags AWS.EC2.Instance resources when the condition below holds.
Condition
Placement.HostIdis not one ofEXAMPLE-HOST-ID
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Placement.HostId | eq | EXAMPLE-HOST-ID | excludes:Placement.HostId field:"Placement.HostId" value:"EXAMPLE-HOST-ID" |
Response runbook
AWS EC2 Instance Approved Instance Type
#This policy ensures that the EC2 instance is running on one of the approved instance types.
Detection logic
# Tags: ['AWS Managed Rules - Compute']
APPROVED_INSTANCE_TYPES = {
"t2.small",
}
def policy(resource):
return resource["InstanceType"] in APPROVED_INSTANCE_TYPES
Rule specification
AnalysisType: policy
Filename: aws_ec2_instance_approved_instance_type.py
PolicyID: "AWS.EC2.Instance.ApprovedInstanceType"
DisplayName: "AWS EC2 Instance Approved Instance Type"
Enabled: false
ResourceTypes:
- AWS.EC2.Instance
Tags:
- AWS
- Configuration Required
Severity: Low
Description: >
This policy ensures that the EC2 instance is running on one of the approved instance types.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-running-on-approved-instance-type
Reference: https://aws.amazon.com/ec2/instance-types/
Stages and Predicates
Flags AWS.EC2.Instance resources when the condition below holds.
Condition
InstanceTypeis not one oft2.small
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
InstanceType | eq | t2.small | excludes:InstanceType field:"InstanceType" value:"t2.small" |
Response runbook
AWS EC2 Instance Approved Tenancy
#This policy ensures the given EC2 Instance is running with an approved tenancy option. The possible tenancy options are dedicated, host, and default.
Detection logic
from panther_base_helpers import deep_get
APPROVED_TENANCIES = {"default"}
def policy(resource):
return deep_get(resource, "Placement", "Tenancy") in APPROVED_TENANCIES
Rule specification
AnalysisType: policy
Filename: aws_ec2_instance_approved_tenancy.py
PolicyID: "AWS.EC2.Instance.ApprovedTenancy"
DisplayName: "AWS EC2 Instance Approved Tenancy"
Enabled: false
ResourceTypes:
- AWS.EC2.Instance
Tags:
- AWS
- Configuration Required
Severity: Low
Description: >
This policy ensures the given EC2 Instance is running with an approved tenancy option. The possible tenancy options are dedicated, host, and default.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-running-with-approved-tenancy
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/dedicated-instance.html
Stages and Predicates
Flags AWS.EC2.Instance resources when the condition below holds.
Condition
Placement.Tenancyis not one ofdefault
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Placement.Tenancy | eq | default | excludes:Placement.Tenancy field:"Placement.Tenancy" value:"default" |
Response runbook
AWS EC2 Instance Approved VPC
#This policy ensures that the given EC2 Instance is running in an approved VPC.
Detection logic
# Tags: ['AWS Managed Rules - Compute']
# This is a list of approved VPC IDs. All EC2 instances must exist in one of these VPCs.
APPROVED_VPCS = {
"EXAMPLE-VPC-ID",
}
# IGNORED_INSTANCE_TAGS is to describe tags that, if present on an EC2 instance, indicate that the
# instance is to be exempted from this rule.
# Example: IGNORED_INSTANCE_TAGS = {'KeyOne': 'ValueOne', 'KeyTwo': 'ValueTwo'}
IGNORED_INSTANCE_TAGS = {
"KeyOne": "ValueOne",
}
def policy(resource):
# Check if any tags on this EC2 instance make it exempt from this rule
if resource["Tags"] is not None:
tags = resource.get("Tags", {})
for tag in tags:
if tag in IGNORED_INSTANCE_TAGS.keys():
if isinstance(IGNORED_INSTANCE_TAGS[tag], str):
if tags[tag] == IGNORED_INSTANCE_TAGS[tag]:
return True
elif isinstance(IGNORED_INSTANCE_TAGS[tag], list):
if tags[tag] in IGNORED_INSTANCE_TAGS[tag]:
return True
return resource["VpcId"] in APPROVED_VPCS
Rule specification
AnalysisType: policy
Filename: aws_ec2_instance_approved_vpc.py
PolicyID: "AWS.EC2.Instance.ApprovedVPC"
DisplayName: "AWS EC2 Instance Approved VPC"
Enabled: false
ResourceTypes:
- AWS.EC2.Instance
Tags:
- AWS
- Configuration Required
Severity: High
Description: >
This policy ensures that the given EC2 Instance is running in an approved VPC.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-running-in-approved-vpc
Reference: https://aws.amazon.com/vpc/
Stages and Predicates
Flags AWS.EC2.Instance resources when all of the conditions below hold.
Condition
Tagsis emptyVpcIdis not one ofEXAMPLE-VPC-ID
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Tags | is_not_null | excludes:Tags | |
VpcId | eq | EXAMPLE-VPC-ID | excludes:VpcId field:"VpcId" value:"EXAMPLE-VPC-ID" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Tags | is_null | field:"Tags" kind:is_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-running-in-approved-vpc
AWS EC2 Instance Detailed Monitoring
#This policy ensures that the AWS Instance has Detailed Monitoring Enabled
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
# per https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instance-status.html
# > 16 Instance.State values indicate shutdown
if deep_get(resource, "State", "Code", default=0) > 16:
return True
return deep_get(resource, "Monitoring", "State") != "disabled"
Rule specification
AnalysisType: policy
Filename: aws_ec2_instance_detailed_monitoring.py
PolicyID: "AWS.EC2.Instance.DetailedMonitoring"
DisplayName: "AWS EC2 Instance Detailed Monitoring"
Enabled: true
ResourceTypes:
- AWS.EC2.Instance
Reports:
MITRE ATT&CK:
- TA0005:T1562
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
- Debug
Severity: Low
Description: >
This policy ensures that the AWS Instance has Detailed Monitoring Enabled
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-has-detailed-monitoring-enabled
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html
Stages and Predicates
Flags AWS.EC2.Instance resources when all of the conditions below hold.
Condition
State.Codeis at most16Monitoring.Stateisdisabled
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Monitoring.State | ne | disabled | excludes:Monitoring.State field:"Monitoring.State" value:"disabled" |
State.Code | gt | 16 | excludes:State.Code field:"State.Code" value:"16" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Monitoring.State | eq |
| field:"Monitoring.State" kind:eq value:"disabled" |
State.Code | le |
| field:"State.Code" kind:le value:"16" |
Response runbook
AWS EC2 Instance EBS Optimization
#This policy ensures EBS optimization is enabled for the given EC2 instance, if applicable.
Detection logic
OPTIMIZABLE_TYPES = {
"c1.xlarge",
"c3.xlarge",
"c3.2xlarge",
"c3.4xlarge",
"g2.2xlarge",
"i2.xlarge",
"i2.2xlarge",
"i2.4xlarge",
"m1.large",
"m1.xlarge",
"m2.2xlarge",
"m2.4xlarge",
"m3.xlarge",
"m3.2xlarge",
"r3.xlarge",
"r3.2xlarge",
"r3.4xlarge",
}
def policy(resource):
# Check if this Instance's instance type can be EBS optimized
if resource["InstanceType"] not in OPTIMIZABLE_TYPES:
return True
# Explicit check for True to avoid returning NoneType
return resource["EbsOptimized"] is True
Rule specification
AnalysisType: policy
Filename: aws_ec2_instance_ebs_optimization.py
PolicyID: "AWS.EC2.Instance.EBSOptimization"
DisplayName: "AWS EC2 Instance EBS Optimization"
Enabled: true
ResourceTypes:
- AWS.EC2.Instance
Tags:
- AWS
- Security Control
Severity: Low
Description: >
This policy ensures EBS optimization is enabled for the given EC2 instance, if applicable.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-is-ebs-optimized
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-optimized.html
Stages and Predicates
Flags AWS.EC2.Instance resources when all of the conditions below hold.
Condition
InstanceTypeis one ofc1.xlarge,c3.xlarge,c3.2xlarge,c3.4xlarge,g2.2xlargeEbsOptimizedis nottrue
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
InstanceType | in | c1.xlarge, c3.2xlarge, c3.4xlarge, c3.xlarge, g2.2xlarge, i2.2xlarge, i2.4xlarge, i2.xlarge, m1.large, m1.xlarge, m2.2xlarge, m2.4xlarge, m3.2xlarge, m3.xlarge, r3.2xlarge, r3.4xlarge, r3.xlarge | excludes:InstanceType |
EbsOptimized | eq | true | excludes:EbsOptimized field:"EbsOptimized" value:"true" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
EbsOptimized | ne |
| field:"EbsOptimized" kind:ne value:"true" |
InstanceType | in |
| field:"InstanceType" kind:in |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-is-ebs-optimized
AWS EC2 Launch Unusual EC2 Instances
#Detect when an actor deploys an EC2 instance with an unusual profile based on your business needs.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Execution |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Modify Cloud Compute Infrastructure (Panther)
Detection logic
from collections.abc import Mapping
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_core import PantherEvent
# Configuration Required
# Add/remove items from the set below as needed. It should contain instance types which aren't
# expected to be used in your environment
UNUSUAL_INSTANCE_TYPES = {
"p2.xlarge" # Large GPU compute, but no graphics - could be used for crypt mining
}
def rule(event: PantherEvent) -> bool:
return (
event.get("eventSource") == "ec2.amazonaws.com"
and event.get("eventName") == "RunInstances"
and get_instance_type(event) in get_unusual_instance_types()
)
def title(event: PantherEvent) -> str:
# The actor in these events is always AutoScalingService
account = event.get("recipientAccountId")
instance_type = get_instance_type(event)
return f"EC2 instance with a suspicious type '{instance_type}' was launched in in {account}"
def severity(event: PantherEvent) -> str:
if not aws_cloudtrail_success(event):
return "LOW"
return "DEFAULT"
def alert_context(event: PantherEvent) -> dict:
context = aws_rule_context(event)
context["instanceType"] = get_instance_type(event)
return context
def get_unusual_instance_types() -> set[str]:
# Making this a separate function allows us to mock it during unit tests for reliable testing!
return UNUSUAL_INSTANCE_TYPES
def get_instance_type(event: PantherEvent) -> str:
# Return the type of the instance that was launch
instance_type = event.deep_get(
"requestParameters", "instanceType", default="<UNKNOWN INSTANCE TYPE>"
)
# instanceType could be a string or a dict
if isinstance(instance_type, Mapping):
instance_type = instance_type.get("value", "<UNKNOWN INSTANCE TYPE>")
return instance_type
Rule specification
AnalysisType: rule
Filename: aws_ec2_launch_unusual_ec2_instances.py
RuleID: "AWS.EC2.LaunchUnusualEC2Instances"
DisplayName: AWS EC2 Launch Unusual EC2 Instances
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
Reports:
MITRE ATT&CK:
- TA0002:T1610 # Execution - Deploy Container
Stratus Red Team:
- aws.execution.ec2-launch-unusual-instances
Description: >
Detect when an actor deploys an EC2 instance with an unusual profile based on
your business needs.
Reference: >
https://stratus-red-team.cloud/attack-techniques/AWS/aws.execution.ec2-launch-unusual-instances/
Runbook: |
Follow up with the instance to identify whether the instance has a legitimate
purpose. Reach out to the actor to ensure they performed the action.
SummaryAttributes:
- p_any_aws_account_ids
- p_any_instance_ids
- p_any_arns
- p_any_aws_tags
- p_any_usernames
Tags:
- CloudTrail
- EC2
- Execution
- Deploy Container
- Execution:Deploy Container
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisec2.amazonaws.comeventNameisRunInstances
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"RunInstances" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"ec2.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Follow up with the instance to identify whether the instance has a legitimate
purpose. Reach out to the actor to ensure they performed the action.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "41fab871-150b-43ad-b42a-39fff3f2ca4e",
"eventName": "RunInstances",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2024-12-16 18:41:07.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.10",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "111122223333",
"requestID": "95cdbe4d-8ff7-4111-8f08-44f510371035",
"requestParameters": {
"availabilityZone": "us-west-2a",
"blockDeviceMapping": {},
"clientToken": "fleet-180da986-0bb4-c936-0c9a-0e20a0c6d1aa-0",
"disableApiStop": false,
"disableApiTermination": false,
"instanceType": "p2.xlarge",
"instancesSet": {
"items": [
{
"maxCount": 1,
"minCount": 1
}
]
},
"monitoring": {
"enabled": false
},
"subnetId": "subnet-083e5906ef2809ac2"
},
"responseElements": {
"groupSet": {},
"instancesSet": {
"items": [
{
"amiLaunchIndex": 0,
"architecture": "arm64",
"blockDeviceMapping": {},
"bootMode": "uefi",
"capacityReservationSpecification": {
"capacityReservationPreference": "open"
},
"clientToken": "fleet-180da986-0bb4-c936-0c9a-0e20a0c6d1aa-0",
"cpuOptions": {
"coreCount": 2,
"threadsPerCore": 1
},
"currentInstanceBootMode": "uefi",
"ebsOptimized": false,
"enaSupport": true,
"enclaveOptions": {
"enabled": false
},
"groupSet": {
"items": [
{
"groupId": "sg-03d704b35372e74e8",
"groupName": "my-group"
}
]
},
"hypervisor": "xen",
"iamInstanceProfile": {
"arn": "arn:aws:iam::111122223333:instance-profile/profile-id",
"id": "PROFILE_ID"
},
"imageId": "ami-013e7d3a6659f358d",
"instanceId": "i-07d06021b0da55115",
"instanceState": {
"code": 0,
"name": "pending"
},
"instanceType": "p2.xlarge",
"launchTime": 1734374467000,
"maintenanceOptions": {
"autoRecovery": "default"
},
"metadataOptions": {
"httpEndpoint": "enabled",
"httpProtocolIpv4": "enabled",
"httpProtocolIpv6": "disabled",
"httpPutResponseHopLimit": 2,
"httpTokens": "required",
"instanceMetadataTags": "disabled",
"state": "pending"
},
"monitoring": {
"state": "disabled"
},
"networkInterfaceSet": {
"items": [
{
"attachment": {
"attachTime": 1734374467000,
"attachmentId": "eni-attach-022e4a3077e096442",
"deleteOnTermination": true,
"deviceIndex": 0,
"networkCardIndex": 0,
"status": "attaching"
},
"groupSet": {
"items": [
{
"groupId": "sg-03d704b35372e74e8",
"groupName": "eks-cluster-sg-k8s-goat-cluster-816437967"
}
]
},
"interfaceType": "interface",
"ipv6AddressesSet": {},
"macAddress": "02:fc:9a:8a:db:c3",
"networkInterfaceId": "eni-03ac9043f76fab96c",
"operator": {
"managed": false
},
"ownerId": "111122223333",
"privateDnsName": "ip-192-168-1-95.us-west-2.compute.internal",
"privateIpAddress": "192.168.1.95",
"privateIpAddressesSet": {
"item": [
{
"primary": true,
"privateDnsName": "ip-192-168-1-95.us-west-2.compute.internal",
"privateIpAddress": "192.168.1.95"
}
]
},
"sourceDestCheck": true,
"status": "in-use",
"subnetId": "subnet-083e5906ef2809ac2",
"tagSet": {},
"vpcId": "vpc-0330bfd33da75b36e"
}
]
},
"operator": {
"managed": false
},
"placement": {
"availabilityZone": "us-west-2a",
"tenancy": "default"
},
"privateDnsName": "ip-192-168-1-95.us-west-2.compute.internal",
"privateDnsNameOptions": {
"enableResourceNameDnsAAAARecord": false,
"enableResourceNameDnsARecord": false,
"hostnameType": "ip-name"
},
"privateIpAddress": "192.168.1.95",
"productCodes": {},
"rootDeviceName": "/dev/xvda",
"rootDeviceType": "ebs",
"sourceDestCheck": true,
"stateReason": {
"code": "pending",
"message": "pending"
},
"subnetId": "subnet-083e5906ef2809ac2",
"tagSet": {
"items": [
{
"key": "k8s.io/cluster-autoscaler/enabled",
"value": "true"
},
{
"key": "aws:autoscaling:groupName",
"value": "eks-ng-0ca246e9-cac9e862-bfd9-a821-c9fd-9916df5654eb"
},
{
"key": "aws:ec2:fleet-id",
"value": "fleet-180da986-0bb4-c936-0c9a-0e20a0c6d1aa"
},
{
"key": "eks:cluster-name",
"value": "k8s-goat-cluster"
},
{
"key": "eks:nodegroup-name",
"value": "ng-0ca246e9"
},
{
"key": "alpha.eksctl.io/nodegroup-name",
"value": "ng-0ca246e9"
},
{
"key": "alpha.eksctl.io/nodegroup-type",
"value": "managed"
},
{
"key": "k8s.io/cluster-autoscaler/k8s-goat-cluster",
"value": "owned"
},
{
"key": "aws:ec2launchtemplate:id",
"value": "lt-07a0b5cea4ece8ffd"
},
{
"key": "aws:ec2launchtemplate:version",
"value": "1"
},
{
"key": "Name",
"value": "k8s-goat-cluster-ng-0ca246e9-Node"
},
{
"key": "kubernetes.io/cluster/k8s-goat-cluster",
"value": "owned"
}
]
},
"virtualizationType": "hvm",
"vpcId": "vpc-0330bfd33da75b36e"
}
]
},
"ownerId": "111122223333",
"requestId": "95cdbe4d-8ff7-4111-8f08-44f510371035",
"requesterId": "414886084714",
"reservationId": "r-0ff0b006325a10345"
},
"sourceIPAddress": "autoscaling.amazonaws.com",
"userAgent": "autoscaling.amazonaws.com",
"userIdentity": {
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/AWSServiceRoleForAutoScaling/AutoScaling",
"invokedBy": "autoscaling.amazonaws.com",
"principalId": "PRINCIPAL_ID:AutoScaling",
"sessionContext": {
"attributes": {
"creationDate": "2024-12-16T18:41:05Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling",
"principalId": "PRINCIPAL_ID",
"type": "Role",
"userName": "AWSServiceRoleForAutoScaling"
}
},
"type": "AssumedRole"
}
}
AWS EC2 Manual Security Group Change
#An EC2 security group was manually updated without abiding by the organization's accepted processes. This rule expects organizations to either use the Console, CloudFormation, or Terraform, configurable in the rule's ALLOWED_USER_AGENTS.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_base_helpers import pattern_match_list
PROD_ACCOUNT_IDS = {"11111111111111", "112233445566"}
SG_CHANGE_EVENTS = {
"CreateSecurityGroup": {
"fields": ["groupName", "vpcId"],
"title": "New security group [{groupName}] created by {actor}",
},
"AuthorizeSecurityGroupIngress": {
"fields": ["groupId"],
"title": "User {actor} has updated security group [{groupId}]",
},
"AuthorizeSecurityGroupEgress": {
"fields": ["groupId"],
"title": "User {actor} has updated security group [{groupId}]",
},
}
ALLOWED_USER_AGENTS = {
"* HashiCorp/?.0 Terraform/*",
# 'console.ec2.amazonaws.com',
# 'cloudformation.amazonaws.com',
}
ALLOWED_ROLE_NAMES = {
"Operator",
"ContinousDeployment",
}
def rule(event):
return aws_cloudtrail_success(event) and (
event.get("eventName") in SG_CHANGE_EVENTS.keys()
and event.get("recipientAccountId") in PROD_ACCOUNT_IDS
and
# Validate the deployment mechanism (Console, CloudFormation, or Terraform)
not (
pattern_match_list(event.get("userAgent"), ALLOWED_USER_AGENTS)
and
# Validate the IAM Role used is in our acceptable list
any(role in event.deep_get("userIdentity", "arn") for role in ALLOWED_ROLE_NAMES)
)
)
def dedup(event):
return ":".join(
event.deep_get("requestParameters", field, default="<UNKNOWN_FIELD>")
for field in SG_CHANGE_EVENTS[event.get("eventName")]["fields"]
)
def title(event):
title_fields = {
field: event.deep_get("requestParameters", field, default="<UNKNOWN_FIELD>")
for field in SG_CHANGE_EVENTS[event.get("eventName")]["fields"]
}
user = event.deep_get("userIdentity", "arn", default="<UNKNOWN_USER>").split("/")[-1]
title_template = SG_CHANGE_EVENTS[event.get("eventName")]["title"]
title_fields["actor"] = user
return title_template.format(**title_fields)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ec2_manual_security_group_changes.py
RuleID: "AWS.EC2.ManualSecurityGroupChange"
DisplayName: "AWS EC2 Manual Security Group Change"
Enabled: false
LogTypes:
- AWS.CloudTrail
Reports:
MITRE ATT&CK:
- TA0005:T1562
Tags:
- AWS
- Security Control
- Configuration Required
- Defense Evasion:Impair Defenses
Severity: Medium
Description: >
An EC2 security group was manually updated without abiding by the organization's accepted processes. This rule expects organizations to either use the Console, CloudFormation, or Terraform, configurable in the rule's ALLOWED_USER_AGENTS.
Runbook: Identify the actor who changed the security group and validate it was legitimate
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/working-with-security-groups.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofCreateSecurityGroup,AuthorizeSecurityGroupIngress,AuthorizeSecurityGroupEgressrecipientAccountIdis one of11111111111111,112233445566any of:
userAgentdoes not match the pattern* HashiCorp/?.0 Terraform/*all of:
userIdentity.arndoes not containOperatoruserIdentity.arndoes not containContinousDeployment
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
userIdentity.arn | contains | ContinousDeployment | excludes:userIdentity.arn field:"userIdentity.arn" value:"ContinousDeployment" |
userIdentity.arn | contains | Operator | excludes:userIdentity.arn field:"userIdentity.arn" value:"Operator" |
userAgent | match | HashiCorp/?.0 Terraform/ | excludes:userAgent field:"userAgent" value:" HashiCorp/?.0 Terraform/" |
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
recipientAccountId | in |
| field:"recipientAccountId" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Identify the actor who changed the security group and validate it was legitimate
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "504b492f-7832-406b-a4fd-45a13e48adc4",
"eventName": "AuthorizeSecurityGroupIngress",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2021-01-24 04:55:45.000",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_any_aws_account_ids": [
"112233445566"
],
"p_any_aws_arns": [
"arn:aws:iam::112233445566:role/TestAdmin",
" arn:aws:sts::112233445566:assumed-role/TestAdmin/alan"
],
"p_any_ip_addresses": [
"136.25.37.134"
],
"p_event_time": "2021-01-24 04:55:45.000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2021-01-24 05:02:58.358",
"p_row_id": "1a57ff7ade26aaf5a1a4d7d20775",
"p_source_id": "e55677c6-7ef5-4541-a443-0d0f17eec19f",
"p_source_label": "CloudTrail Test",
"readOnly": false,
"recipientAccountId": "112233445566",
"requestID": "91f34d65-513d-4e9f-a3de-e8d27f7ee4b2",
"requestParameters": {
"groupId": "sg-04f0b44316f7d2471",
"ipPermissions": {
"items": [
{
"fromPort": "443",
"groups": {},
"ipProtocol": "tcp",
"ipRanges": {
"items": [
{
"cidrIp": "0.0.0.0/16"
}
]
},
"ipv6Ranges": {},
"prefixListIds": {},
"toPort": "443"
}
]
}
},
"responseElements": {
"_return": true,
"requestId": "91f34d65-513d-4e9f-a3de-e8d27f7ee4b2"
},
"sourceIPAddress": "136.25.37.134",
"userAgent": "console.ec2.amazonaws.com",
"userIdentity": {
"accesskeyid": "ASIASWJRT64Z7ZLFLJNI",
"accountid": "112233445566",
"arn": "arn:aws:sts::112233445566:assumed-role/TestAdmin/alan",
"principalid": "ARORJ4ULULLE0EEJAAKDO:alan",
"sessioncontext": {
"attributes": {
"creationdate": "2021-01-24T04:55:10Z",
"mfaauthenticated": "true"
},
"sessionissuer": {
"accountid": "112233445566",
"arn": "arn:aws:iam::112233445566:role/TestAdmin",
"principalid": "ARORJ4ULULLE0EEJAAKDO",
"type": "Role",
"username": "TestAdmin"
}
},
"type": "AssumedRole"
}
}
AWS EC2 Many Password Read Attempts
#An actor in AWS has made many attempts to retrieve EC2 passwords. It is typically not necessary to retrieve EC2 passwords more than a few times an hour.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event GetPasswordData: Retrieves the encrypted administrator password for a Windows instance. |
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_core import PantherEvent
def rule(event: PantherEvent) -> bool:
# Raise alert if this is a GetPasswordData event for an EC2 service
return (
event.get("eventName") == "GetPasswordData"
and event.get("eventSource") == "ec2.amazonaws.com"
)
def title(event: PantherEvent) -> str:
actor = event.udm("actor_user")
return f"{actor} has made multiple requests for EC2 password data in the last hour"
def dedup(event: PantherEvent) -> str:
# Dedup events based on the principal ID
return event.udm("actor_user")
def severity(event: PantherEvent) -> str:
# Return "INFO" severity if the password read attempts are unsuccessful
if not aws_cloudtrail_success(event):
return "INFO"
return "DEFAULT"
def alert_context(event: PantherEvent) -> dict:
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ec2_many_passwors_read_attempts.py
RuleID: "AWS.EC2.ManyPasswordReadAttempts"
DisplayName: "AWS EC2 Many Password Read Attempts"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
Reports:
MITRE ATT&CK:
- TA0006:T1555 # Credential Access: Credentials from Password Stores
Stratus Red Team:
- aws.credential-access.ec2-get-password-data
Description: >
An actor in AWS has made many attempts to retrieve EC2 passwords. It is typically
not necessary to retrieve EC2 passwords more than a few times an hour.
DedupPeriodMinutes: 60
Threshold: 30
Reference:
https://stratus-red-team.cloud/attack-techniques/AWS/aws.credential-access.ec2-get-password-data/
Runbook: Identify the actor and the EC2 instances for which the credential access
attempts were made. Determine if the attempts have a valid reason.
Tags:
- AWS
- CloudTrail
- EC2
- Credential Access:Credentials from Password Stores
- Credential Access
- Credentials from Password Stores
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisGetPasswordDataeventSourceisec2.amazonaws.com
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"GetPasswordData" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"ec2.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
Identify the actor and the EC2 instances for which the credential access attempts were made. Determine if the attempts have a valid reason.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"errorCode": "Client.UnauthorizedOperation",
"errorMessage": "You are not authorized to perform this operation. User: arn:aws:sts::111111111111:assumed-role/sample-role-nostalgic-merkle/sample-role-quizzical-goldstine.jenkins is not authorized to perform: ec2:GetPasswordData on resource: arn:aws:ec2:us-west-2:111111111111:instance/* because no identity-based policy allows the ec2:GetPasswordData action. Encoded authorization failure message: VbdJrM7j1Czq6HEj1lZpnU-AWGICaaPZQs1K_-9U3hQcFpXimBHYHwvg4SOc36ACu0GUYqWoSeX6_wI7ke3QxC0d1_Wv2XYLI96rYdMY2aWzdzwkIp__hUQQi-XqaHmp-QHOHiiJ31xqEkDZvyZXaO0BHhCpf8m7mIMeMaAB2CPtKhPj5NPGGkPc1f6rNFx0grhDkKZ3MrWBo65U4nRjzJrThuyK3146B1k1tWuQfI2_H-QMCuOl_aTIZ93xIeWFoIqKUWnD6-F68V8hhxHHXl0EhiFL9p7LAGvYTPXtJ1wEH1ve8iOW1S9ptI8CuFVP-Q7E7-NS45tIaheVJusaq3JtS03XAnKYC2NuVXnXBwbPNbNiQWH8LfSdgl43MZ5Q9Kin-tqCoA_Yskz0F_JNokjmIB2PKegJ5kANzHcb09u9sSqvgqKqpVHpfIDtjcI8LPzWjZyUNExaymEWOkE4HhtF19t1zyBvuoO6xgZtCyAx-6fsDSO8jpBZbLz9MsPmjhJLfp_yQPOF9ROIrBhvNCY_2tC7hyDDwdl11iNzHQvBaCjiLjE5PcoEchYWTHqlVHZ-yMA3",
"eventCategory": "Management",
"eventID": "9e2b3bf0-8f58-4d50-83af-b3175b68c2f8",
"eventName": "GetPasswordData",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2024-12-11 21:45:56.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.10",
"managementEvent": true,
"p_event_time": "2024-12-11 21:45:56.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2024-12-11 21:50:54.495464364",
"readOnly": true,
"recipientAccountId": "111111111111",
"requestID": "b22ce7ac-297f-41b4-a92e-f4aa38668c6d",
"requestParameters": {
"instanceId": "i-abcdef1234567890"
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "ec2.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "SampleUserAgent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111111111111",
"arn": "arn:aws:sts::111111111111:assumed-role/sample-role-nostalgic-merkle/sample-role-quizzical-goldstine.jenkins",
"principalId": "SAMPLE_PRINCIPAL_ID:leroy.jenkins",
"sessionContext": {
"attributes": {
"creationDate": "2024-12-11T21:45:52Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111111111111",
"arn": "arn:aws:iam::111111111111:role/sample-role-nostalgic-merkle",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS EC2 Multi Instance Connect
#Detect when an attacker pushes an SSH public key to multiple EC2 instances.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Lateral Movement |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_core import PantherEvent
def rule(event: PantherEvent) -> bool:
return aws_cloudtrail_success(event) and event.get("eventName") == "SendSSHPublicKey"
def unique(event: PantherEvent) -> str:
return event.deep_get("requestParameters", "instanceId", default="")
def dedup(event: PantherEvent) -> str:
return event.deep_get("requestParameters", "sSHPublicKey", default="")
def title(event: PantherEvent) -> str:
actor = event.udm("actor_user")
account_name = event.get("recipientAccountId")
return f"{actor} uploaded an SSH Key to multiple instances in {account_name}"
def alert_context(event: PantherEvent) -> dict:
context = aws_rule_context(event)
context["instanceId"] = event.deep_get(
"requestParameters", "instanceId", default="<UNKNOWN EC2 INSTANCE ID>"
)
return context
Rule specification
AnalysisType: rule
Filename: aws_ec2_multi_instance_connect.py
RuleID: "AWS.EC2.MultiInstanceConnect"
DisplayName: AWS EC2 Multi Instance Connect
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
Reports:
MITRE ATT&CK:
- TA0008:T1021.005 # Lateral Movement: Remote Services: SSH
Stratus Red Team:
- aws.lateral-movement.ec2-instance-connect
Description: >
Detect when an attacker pushes an SSH public key to multiple EC2 instances.
DedupPeriodMinutes: 60
Threshold: 2
Reference: >
https://stratus-red-team.cloud/attack-techniques/AWS/aws.lateral-movement.ec2-instance-connect/
Runbook: |
Followup with the actor to determine if the SSH key is genuine. Consider using a different SSH key for each instance.
SummaryAttributes:
- p_any_actor_ids
- p_any_aws_account_ids
- p_any_aws_instance_ids
- p_any_usernames
Tags:
- AWS CloudTrail
- Lateral Movement
- Remote Services
- SSH
- Lateral Movement:Remote Services
Status: Experimental
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisSendSSHPublicKey
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"SendSSHPublicKey" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
Followup with the actor to determine if the SSH key is genuine. Consider using a different SSH key for each instance.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "d6d05dd2-d03d-4dce-a88c-02b6a567d889",
"eventName": "SendSSHPublicKey",
"eventSource": "ec2-instance-connect.amazonaws.com",
"eventTime": "2025-01-13 19:58:59.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_event_time": "2025-01-13 19:58:59.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2025-01-13 20:05:54.575569351",
"readOnly": false,
"recipientAccountId": "111122223333",
"requestID": "25eac5d1-cd24-4156-a49b-f2bf3a20ec9d",
"requestParameters": {
"instanceId": "i-abcdef01234567890",
"instanceOSUser": "ec2-user",
"sSHPublicKey": "ssh-ed25519 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
},
"responseElements": {
"requestId": "25eac5d1-cd24-4156-a49b-f2bf3a20ec9d",
"success": true
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "ec2-instance-connect.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "stratus-red-team_8b255a24-d33d-4750-bd4b-4007124741df",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/bobson.dugnutt",
"principalId": "SAMPLE_PRINCIPAL_ID:bobson.dugnutt",
"sessionContext": {
"attributes": {
"creationDate": "2025-01-13T19:21:25Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS EC2 Startup Script Change
#Detects changes to the EC2 instance startup script. The shell script will be executed as root/SYSTEM every time the specific instances are booted up.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Execution |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventName") == "ModifyInstanceAttribute" and event.deep_get(
"requestParameters", "userData"
):
return True
return False
def title(event):
return (
f"[{event.deep_get('userIdentity','arn')}] "
"modified the startup script for "
f" [{event.deep_get('requestParameters', 'instanceId')}] "
f"in [{event.get('recipientAccountId')}] - [{event.get('awsRegion')}]"
)
def dedup(event):
return event.deep_get("requestParameters", "instanceId")
def alert_context(event):
context = aws_rule_context(event)
context["instance_ids"] = [event.deep_get("requestParameters", "instanceId"), "no_instance_id"]
return context
Rule specification
AnalysisType: rule
Description: Detects changes to the EC2 instance startup script. The shell script will be executed as root/SYSTEM every time the specific instances are booted up.
DisplayName: "AWS EC2 Startup Script Change"
Enabled: true
Filename: aws_ec2_startup_script_change.py
Reports:
MITRE ATT&CK:
- TA0002:T1059
Stratus Red Team:
- aws.execution.ec2-user-data
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html#user-data-shell-scripts
Severity: High
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.EC2.Startup.Script.Change"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisModifyInstanceAttributerequestParameters.userDatais present
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"ModifyInstanceAttribute" |
requestParameters.userData | is_not_null | field:"requestParameters.userData" kind:is_not_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
instanceId | requestParameters.instanceId |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-2",
"eventCategory": "Management",
"eventName": "ModifyInstanceAttribute",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2022-09-30 15:11:25.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "0123456789",
"requestID": "example-id",
"requestParameters": {
"instanceId": "i-012345abcde",
"userData": "<sensitiveDataRemoved>"
},
"responseElements": {
"_return": true,
"requestId": "012345abcdef"
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"clientProvidedHostHeader": "ec2.us-east-2.amazonaws.com",
"tlsVersion": "TLSv1.2"
},
"userAgent": "aws sdk",
"userIdentity": {
"accessKeyId": "ABCDEXAMPLE123",
"accountId": "0123456789",
"arn": "arn:aws:sts::0123456789:assumed-role/Role-us-1/CodeBuild",
"principalId": "ABCDEXAMPLE:CodeBuild",
"sessionContext": {
"attributes": {
"creationDate": "2022-09-30T14:52:26Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "0123456789",
"arn": "arn:aws:iam::0123456789:role/CodeBuild-US-East",
"principalId": "AROAQUW22FGSKBTQ7R5HP",
"type": "Role",
"userName": "CodeBuild-US-East"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS EC2 Traffic Mirroring
#This rule captures multiple traffic mirroring events in AWS Cloudtrail.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access | |
| Discovery |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
# Return True to match the log event and trigger an alert.
event_names = [
"CreateTrafficMirrorFilter",
"CreateTrafficMirrorFilterRule",
"CreateTrafficMirrorSession",
"CreateTrafficMirrorTarget",
"DeleteTrafficMirrorFilter",
"DeleteTrafficMirrorFilterRule",
"DeleteTrafficMirrorSession",
"DeleteTrafficMirrorTarget",
# "DescribeTrafficMirrorFilters",
# "DescribeTrafficMirrorSessions",
# "DescribeTrafficMirrorTargets",
"ModifyTrafficMirrorFilterNetworkServices",
"ModifyTrafficMirrorFilterRule",
"ModifyTrafficMirrorSession",
]
if event.deep_get("userIdentity", "invokedBy", default="").endswith(".amazonaws.com"):
return False
return (
event.get("eventSource", "") == "ec2.amazonaws.com"
and event.get("eventName", "") in event_names
)
def title(event):
return (
f"{event.get('userIdentity',{}).get('arn','no-type')} ec2 activity found for "
f"{event.get('eventName')} in account {event.get('recipientAccountId')} "
f"in region {event.get('awsRegion')}."
)
def dedup(event):
return f"{event.get('userIdentity',{}).get('arn','no-user-identity-provided')}"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: This rule captures multiple traffic mirroring events in AWS Cloudtrail.
DisplayName: "AWS EC2 Traffic Mirroring"
Enabled: true
Filename: aws_ec2_traffic_mirroring.py
Reference: https://attack.mitre.org/techniques/T1040/
Runbook: Examine other activities done by this user to determine whether or not activity is suspicious. If your network traffic is not encrypted, we recommend changing the severity to high or critical.
Severity: Medium
Tags:
- AWS
- Cloudtrail
- MITRE
DedupPeriodMinutes: 1440
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.EC2.Traffic.Mirroring"
SummaryAttributes:
- userIdentity.type
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
userIdentity.invokedBydoes not end with.amazonaws.comeventSourceisec2.amazonaws.comeventNameis one ofCreateTrafficMirrorFilter,CreateTrafficMirrorFilterRule,CreateTrafficMirrorSession,CreateTrafficMirrorTarget,DeleteTrafficMirrorFilter
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
userIdentity.invokedBy | ends_with | .amazonaws.com | excludes:userIdentity.invokedBy field:"userIdentity.invokedBy" value:".amazonaws.com" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"ec2.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Response runbook
Examine other activities done by this user to determine whether or not activity is suspicious. If your network traffic is not encrypted, we recommend changing the severity to high or critical.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "a3c6297b-3320-4d32-b224-cc45ee75d561",
"eventName": "CreateTrafficMirrorFilter",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2022-11-15 22:58:13",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_any_aws_account_ids": [
"123451234513"
],
"p_any_aws_arns": [
"arn:aws:iam::123451234513:role/MakeStuffPublic",
"arn:aws:sts::123451234513:assumed-role/MakeStuffPublic"
],
"p_any_domain_names": [
"AWS Internal"
],
"p_any_trace_ids": [
"ASIA57JLR4M2ZZDJUXY3"
],
"p_any_usernames": [
"MakeStuffPublic"
],
"p_event_time": "2022-11-15 22:58:13",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2022-11-15 23:00:37.355",
"p_row_id": "82670d2e7575bbd0e8fc97d014b5a80c",
"p_source_id": "125a8146-e3ea-454b-aed7-9e08e735b670",
"p_source_label": "Panther Identity Org CloudTrail",
"readOnly": false,
"recipientAccountId": "123451234515",
"requestID": "200a9157-dff7-4578-87d6-205b01d90a56",
"requestParameters": {
"CreateTrafficMirrorFilterRequest": {
"ClientToken": "5b7eff74-2b70-4f92-8aa1-9c716bf151aa"
}
},
"responseElements": {
"CreateTrafficMirrorFilterResponse": {
"clientToken": "5b7eff74-2b70-4f92-8aa1-9c716bf151aa",
"requestId": "200a9157-dff7-4578-87d6-205b01d90a56",
"trafficMirrorFilter": {
"egressFilterRuleSet": "",
"ingressFilterRuleSet": "",
"networkServiceSet": "",
"tagSet": "",
"trafficMirrorFilterId": "tmf-010db9a7d8056cc2d"
},
"xmlns": "http://ec2.amazonaws.com/doc/2016-11-15/"
}
},
"sessionCredentialFromConsole": true,
"sourceIPAddress": "AWS Internal",
"userAgent": "AWS Internal",
"userIdentity": {
"accessKeyId": "ASIA57JLR4M2ZZDJUXY3",
"accountId": "123451234516",
"arn": "arn:aws:sts::123123123123:assumed-role/MakeStuffPublic",
"principalId": "AROA57JLR4M2SBAPVC4BO",
"sessionContext": {
"attributes": {
"creationDate": "2022-11-15T22:38:17Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123123123123",
"arn": "arn:aws:iam::123123123123:role/MakeStuffPublic",
"principalId": "AROA57JLR4M2SBAPVC4BO",
"type": "Role",
"userName": "MakeStuffPublic"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS EC2 Volume Encryption
#You can encrypt both the boot and data volumes of an EC2 instance.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
def policy(resource):
# Only check the volumes that are "in-use" and ignore the rest
if resource["State"] != "in-use":
return True
return bool(resource["Encrypted"])
Rule specification
AnalysisType: policy
Filename: aws_ec2_volume_encryption.py
PolicyID: "AWS.EC2.Volume.Encryption"
DisplayName: "AWS EC2 Volume Encryption"
Enabled: true
ResourceTypes:
- AWS.EC2.Volume
Tags:
- AWS
- Data Protection
- Collection:Data From Local System
Reports:
MITRE ATT&CK:
- TA0009:T1005
Severity: High
Description: >
You can encrypt both the boot and data volumes of an EC2 instance.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-volumes-are-encrypted
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html
Stages and Predicates
Flags AWS.EC2.Volume resources when all of the conditions below hold.
Condition
Stateisin-useEncryptedis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Encrypted | is_not_null | excludes:Encrypted | |
State | ne | in-use | excludes:State field:"State" value:"in-use" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Encrypted | is_null | field:"Encrypted" kind:is_null | |
State | eq |
| field:"State" kind:eq value:"in-use" |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-instance-volumes-are-encrypted
AWS EC2 Volume Snapshot Encryption
#You can encrypt the snapshot of an EC2 volume to protect against accidental data loss
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
def policy(resource):
for snapshot in resource["Snapshots"] or []:
if snapshot["State"] != "completed":
continue
if not bool(snapshot["Encrypted"]):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_ec2_volume_snapshot_encrypted.py
PolicyID: "AWS.EC2.Volume.Snapshot.Encrypted"
DisplayName: "AWS EC2 Volume Snapshot Encryption"
Enabled: true
ResourceTypes:
- AWS.EC2.Volume
Tags:
- AWS
- Panther
- Collection:Data From Cloud Storage Object
Reports:
MITRE ATT&CK:
- TA0009:T1530
Severity: High
Description: >
You can encrypt the snapshot of an EC2 volume to protect against accidental data loss
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-volume-is-encrypted
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html
Stages and Predicates
Flags AWS.EC2.Volume resources when the condition below holds.
Condition
any element of
Snapshotsmatches all of:Snapshots.StateiscompletedSnapshots.Encryptedis empty
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-ec2-volume-is-encrypted
AWS EC2 Vulnerable XZ Image Launched
#Detects EC2 instances launched using Amazon Machine Images containing vulnerable XZ Utils library versions (5.6.0 or 5.6.1) affected by CVE-2024-3094. This critical supply chain vulnerability introduced a backdoor allowing remote attackers to bypass SSH authentication on affected Linux systems. The rule monitors CloudTrail RunInstances events and checks AMI IDs against known vulnerable images.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_iocs import XZ_AMIS
# AMIs published by Fedora between 2024-03-26 and 2024-04-02
# OpenSUSE and Kali do not have any recent [public] AMIs that would be affected
def rule(event):
if not aws_cloudtrail_success(event) or event.get("eventName") != "RunInstances":
return False
amis_launched = event.deep_walk(
"responseElements",
"instancesSet",
"items",
"imageId",
default="<AMI ID not found>",
return_val="all",
)
# convert to a list if only one item is returned
if not isinstance(amis_launched, list):
amis_launched = [amis_launched]
if any(ami in XZ_AMIS for ami in amis_launched):
return True
return False
def title(event):
amis_launched = event.deep_walk(
"responseElements",
"instancesSet",
"items",
"imageId",
default="<AMI ID not found>",
return_val="all",
)
instance_ids = event.deep_walk(
"responseElements",
"instancesSet",
"items",
"instanceId",
default="<Instance ID not found>",
return_val="all",
)
return f"Instance {instance_ids} launched with vulnerable AMI: {amis_launched}"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: >
Detects EC2 instances launched using Amazon Machine Images containing vulnerable XZ Utils library versions (5.6.0 or 5.6.1) affected by CVE-2024-3094. This critical supply chain vulnerability introduced a backdoor allowing remote attackers to bypass SSH authentication on affected Linux systems. The rule monitors CloudTrail RunInstances events and checks AMI IDs against known vulnerable images.
DisplayName: "AWS EC2 Vulnerable XZ Image Launched"
Enabled: true
Filename: aws_ec2_vulnerable_xz_image_launched.py
Reference: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-3094
Severity: High
Tags:
- AWS
- Linux
- Emerging Threats
- Supply Chain Compromise
Reports:
MITRE ATT&CK:
- TA0001:T1195.001
Runbook: |
1. Query CloudTrail for all RunInstances events using the same responseElements.instancesSet.items.imageId across all regions in the 30 days before this alert to identify other instances launched with vulnerable XZ AMIs
2. For the launched instance ID in responseElements.instancesSet.items.instanceId, verify the XZ Utils version using AWS Systems Manager Session Manager by running `xz --version` to confirm if version 5.6.0 or 5.6.1 is installed
3. Review CloudTrail logs for userIdentity.arn in the 24 hours before the launch to determine if this was automated infrastructure deployment or manual execution, and check for other suspicious API calls from the same principal
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.EC2.Vulnerable.XZ.Image.Launched"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisRunInstances
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | ne | RunInstances | excludes:eventName field:"eventName" value:"RunInstances" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
instanceId | responseElements.instancesSet.items.instanceId |
imageId | responseElements.instancesSet.items.imageId |
Response runbook
1. Query CloudTrail for all RunInstances events using the same responseElements.instancesSet.items.imageId across all regions in the 30 days before this alert to identify other instances launched with vulnerable XZ AMIs
2. For the launched instance ID in responseElements.instancesSet.items.instanceId, verify the XZ Utils version using AWS Systems Manager Session Manager by running xz --version to confirm if version 5.6.0 or 5.6.1 is installed
3. Review CloudTrail logs for userIdentity.arn in the 24 hours before the launch to determine if this was automated infrastructure deployment or manual execution, and check for other suspicious API calls from the same principal
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "cd7919fe-34a2-4d26-b038-23a2556a79fb",
"eventName": "RunInstances",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2024-04-02 15:13:06.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.09",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "439a7e66-d8b6-4bad-98d9-214c20161939",
"requestParameters": {
"blockDeviceMapping": {
"items": [
{
"deviceName": "/dev/sda1",
"ebs": {
"deleteOnTermination": true,
"encrypted": false,
"iops": 3000,
"snapshotId": "snap-00000000000000000",
"throughput": 125,
"volumeSize": 10,
"volumeType": "gp3"
}
}
]
},
"clientToken": "00000000-0000-0000-0000-000000000000",
"disableApiStop": false,
"disableApiTermination": false,
"ebsOptimized": true,
"instanceType": "t3a.micro",
"instancesSet": {
"items": [
{
"imageId": "ami-020a359780bc6f835",
"keyName": "a-key",
"maxCount": 1,
"minCount": 1
}
]
},
"monitoring": {
"enabled": false
},
"networkInterfaceSet": {
"items": [
{
"associatePublicIpAddress": true,
"deviceIndex": 0,
"groupSet": {
"items": [
{
"groupId": "sg-00000000000000000"
}
]
},
"subnetId": "subnet-00000000000000000"
}
]
},
"privateDnsNameOptions": {
"enableResourceNameDnsAAAARecord": false,
"enableResourceNameDnsARecord": false,
"hostnameType": "ip-name"
},
"tagSpecificationSet": {
"items": [
{
"resourceType": "instance",
"tags": [
{
"key": "Name",
"value": "test"
}
]
}
]
}
},
"responseElements": {
"groupSet": {},
"instancesSet": {
"items": [
{
"amiLaunchIndex": 0,
"architecture": "x86_64",
"blockDeviceMapping": {},
"capacityReservationSpecification": {
"capacityReservationPreference": "open"
},
"clientToken": "8cda61da-eea9-495c-b178-e7014d9bc212",
"cpuOptions": {
"coreCount": 1,
"threadsPerCore": 2
},
"currentInstanceBootMode": "legacy-bios",
"ebsOptimized": true,
"enaSupport": true,
"enclaveOptions": {
"enabled": false
},
"groupSet": {
"items": [
{
"groupId": "sg-00000000000000000",
"groupName": "ssh"
}
]
},
"hypervisor": "xen",
"imageId": "ami-020a359780bc6f835",
"instanceId": "i-00000000000000000",
"instanceState": {
"code": 0,
"name": "pending"
},
"instanceType": "t3a.micro",
"keyName": "a-key",
"launchTime": 1712070786000,
"maintenanceOptions": {
"autoRecovery": "default"
},
"metadataOptions": {
"httpEndpoint": "enabled",
"httpProtocolIpv4": "enabled",
"httpProtocolIpv6": "disabled",
"httpPutResponseHopLimit": 1,
"httpTokens": "optional",
"instanceMetadataTags": "disabled",
"state": "pending"
},
"monitoring": {
"state": "disabled"
},
"networkInterfaceSet": {
"items": [
{
"attachment": {
"attachTime": 1712070786000,
"attachmentId": "eni-attach-00000000000000000",
"deleteOnTermination": true,
"deviceIndex": 0,
"networkCardIndex": 0,
"status": "attaching"
},
"groupSet": {
"items": [
{
"groupId": "sg-00000000000000000",
"groupName": "ssh"
}
]
},
"interfaceType": "interface",
"ipv6AddressesSet": {},
"macAddress": "06:71:c7:76:de:4f",
"networkInterfaceId": "eni-00000000000000000",
"ownerId": "123456789012",
"privateDnsName": "ip-10-0-0-3.us-west-2.compute.internal",
"privateIpAddress": "10.0.0.3",
"privateIpAddressesSet": {
"item": [
{
"primary": true,
"privateDnsName": "ip-10-0-0-3.us-west-2.compute.internal",
"privateIpAddress": "10.0.0.3"
}
]
},
"sourceDestCheck": true,
"status": "in-use",
"subnetId": "subnet-00000000000000000",
"tagSet": {},
"vpcId": "vpc-00000000000000000"
}
]
},
"placement": {
"availabilityZone": "us-west-2b",
"tenancy": "default"
},
"privateDnsName": "ip-10-0-0-3.us-west-2.compute.internal",
"privateDnsNameOptions": {
"enableResourceNameDnsAAAARecord": false,
"enableResourceNameDnsARecord": false,
"hostnameType": "ip-name"
},
"privateIpAddress": "10.0.0.3",
"productCodes": {},
"rootDeviceName": "/dev/sda1",
"rootDeviceType": "ebs",
"sourceDestCheck": true,
"stateReason": {
"code": "pending",
"message": "pending"
},
"subnetId": "subnet-00000000000000000",
"tagSet": {
"items": [
{
"key": "Name",
"value": "test"
}
]
},
"virtualizationType": "hvm",
"vpcId": "vpc-00000000000000000"
}
]
},
"ownerId": "123456789012",
"requestId": "439a7e66-d8b6-4bad-98d9-214c20161939",
"reservationId": "r-00000000000000000"
},
"sessionCredentialFromConsole": true,
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "ec2.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0",
"userIdentity": {
"accessKeyId": "ASIASXP6SDP2MXGX4MYC",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/ARole/user.name",
"principalId": "AROAVKVYIOO7JN7TN7NSA:user.name",
"sessionContext": {
"attributes": {
"creationDate": "2024-04-02T14:45:31Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/us-west-2/ARole",
"principalId": "AROAVKVYIOO7JN7TN7NSA",
"type": "Role",
"userName": "ARole"
}
},
"type": "AssumedRole"
}
}
AWS ECR Events
#An ECR event occurred outside of an expected account or region
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Stealth |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
# CONFIGURATION REQUIRED: Update with your expected AWS Accounts/Regions
AWS_ACCOUNTS_AND_REGIONS = {
"123456789012": {"us-west-1", "us-west-2"},
"103456789012": {"us-east-1", "us-east-2"},
}
def rule(event):
if event.get("eventSource") == "ecr.amazonaws.com":
aws_account_id = event.deep_get("userIdentity", "accountId")
if aws_account_id in AWS_ACCOUNTS_AND_REGIONS:
if event.get("awsRegion") not in AWS_ACCOUNTS_AND_REGIONS[aws_account_id]:
return True
else:
return True
return False
def dedup(event):
return event.get("recipientAccountId")
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ecr_events.py
RuleID: "AWS.ECR.EVENTS"
DisplayName: "AWS ECR Events"
Enabled: false
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Configuration Required
Reports:
MITRE ATT&CK:
- TA0005:T1535
Severity: Info
CreateAlert: false
Description: An ECR event occurred outside of an expected account or region
Runbook: https://docs.aws.amazon.com/AmazonECR/latest/userguide/logging-using-cloudtrail.html
Reference: https://aws.amazon.com/blogs/containers/amazon-ecr-in-multi-account-and-multi-region-architectures/
SummaryAttributes:
- eventSource
- recipientAccountId
- awsRegion
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventSourceisecr.amazonaws.com
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"ecr.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.aws.amazon.com/AmazonECR/latest/userguide/logging-using-cloudtrail.html
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-2",
"eventID": "2bfd4ee2-2178-4a82-a27d-b12939923f0f",
"eventName": "PutImage",
"eventSource": "ecr.amazonaws.com",
"eventTime": "2019-04-15T16:45:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.04",
"recipientAccountId": "123456789012",
"requestID": "cf044b7d-5f9d-11e9-9b2a-95983139cc57",
"requestParameters": {
"imageManifest": "{\n \"schemaVersion\": 2,\n \"mediaType\": \"application/vnd.docker.distribution.manifest.v2+json\",\n \"config\": {\n \"mediaType\": \"application/vnd.docker.container.image.v1+json\",\n \"size\": 5543,\n \"digest\": \"sha256:000b9b805af1cdb60628898c9f411996301a1c13afd3dbef1d8a16ac6dbf503a\"\n },\n \"layers\": [\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 43252507,\n \"digest\": \"sha256:3b37166ec61459e76e33282dda08f2a9cd698ca7e3d6bc44e6a6e7580cdeff8e\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 846,\n \"digest\": \"sha256:504facff238fde83f1ca8f9f54520b4219c5b8f80be9616ddc52d31448a044bd\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 615,\n \"digest\": \"sha256:ebbcacd28e101968415b0c812b2d2dc60f969e36b0b08c073bf796e12b1bb449\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 850,\n \"digest\": \"sha256:c7fb3351ecad291a88b92b600037e2435c84a347683d540042086fe72c902b8a\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 168,\n \"digest\": \"sha256:2e3debadcbf7e542e2aefbce1b64a358b1931fb403b3e4aeca27cb4d809d56c2\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 37720774,\n \"digest\": \"sha256:f8c9f51ad524d8ae9bf4db69cd3e720ba92373ec265f5c390ffb21bb0c277941\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 30432107,\n \"digest\": \"sha256:813a50b13f61cf1f8d25f19fa96ad3aa5b552896c83e86ce413b48b091d7f01b\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 197,\n \"digest\": \"sha256:7ab043301a6187ea3293d80b30ba06c7bf1a0c3cd4c43d10353b31bc0cecfe7d\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 154,\n \"digest\": \"sha256:67012cca8f31dc3b8ee2305e7762fee20c250513effdedb38a1c37784a5a2e71\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 176,\n \"digest\": \"sha256:3bc892145603fffc9b1c97c94e2985b4cb19ca508750b15845a5d97becbd1a0e\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 183,\n \"digest\": \"sha256:6f1c79518f18251d35977e7e46bfa6c6b9cf50df2a79d4194941d95c54258d18\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 212,\n \"digest\": \"sha256:b7bcfbc2e2888afebede4dd1cd5eebf029bb6315feeaf0b56e425e11a50afe42\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 212,\n \"digest\": \"sha256:2b220f8b0f32b7c2ed8eaafe1c802633bbd94849b9ab73926f0ba46cdae91629\"\n }\n ]\n}",
"imageTag": "latest",
"registryId": "123456789012",
"repositoryName": "testrepo"
},
"resources": [
{
"ARN": "arn:aws:ecr:us-east-2:123456789012:repository/testrepo",
"accountId": "123456789012"
}
],
"responseElements": {
"image": {
"imageId": {
"imageDigest": "sha256:98c8b060c21d9adbb6b8c41b916e95e6307102786973ab93a41e8b86d1fc6d3e",
"imageTag": "latest"
},
"imageManifest": "{\n \"schemaVersion\": 2,\n \"mediaType\": \"application/vnd.docker.distribution.manifest.v2+json\",\n \"config\": {\n \"mediaType\": \"application/vnd.docker.container.image.v1+json\",\n \"size\": 5543,\n \"digest\": \"sha256:000b9b805af1cdb60628898c9f411996301a1c13afd3dbef1d8a16ac6dbf503a\"\n },\n \"layers\": [\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 43252507,\n \"digest\": \"sha256:3b37166ec61459e76e33282dda08f2a9cd698ca7e3d6bc44e6a6e7580cdeff8e\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 846,\n \"digest\": \"sha256:504facff238fde83f1ca8f9f54520b4219c5b8f80be9616ddc52d31448a044bd\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 615,\n \"digest\": \"sha256:ebbcacd28e101968415b0c812b2d2dc60f969e36b0b08c073bf796e12b1bb449\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 850,\n \"digest\": \"sha256:c7fb3351ecad291a88b92b600037e2435c84a347683d540042086fe72c902b8a\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 168,\n \"digest\": \"sha256:2e3debadcbf7e542e2aefbce1b64a358b1931fb403b3e4aeca27cb4d809d56c2\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 37720774,\n \"digest\": \"sha256:f8c9f51ad524d8ae9bf4db69cd3e720ba92373ec265f5c390ffb21bb0c277941\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 30432107,\n \"digest\": \"sha256:813a50b13f61cf1f8d25f19fa96ad3aa5b552896c83e86ce413b48b091d7f01b\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 197,\n \"digest\": \"sha256:7ab043301a6187ea3293d80b30ba06c7bf1a0c3cd4c43d10353b31bc0cecfe7d\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 154,\n \"digest\": \"sha256:67012cca8f31dc3b8ee2305e7762fee20c250513effdedb38a1c37784a5a2e71\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 176,\n \"digest\": \"sha256:3bc892145603fffc9b1c97c94e2985b4cb19ca508750b15845a5d97becbd1a0e\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 183,\n \"digest\": \"sha256:6f1c79518f18251d35977e7e46bfa6c6b9cf50df2a79d4194941d95c54258d18\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 212,\n \"digest\": \"sha256:b7bcfbc2e2888afebede4dd1cd5eebf029bb6315feeaf0b56e425e11a50afe42\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 212,\n \"digest\": \"sha256:2b220f8b0f32b7c2ed8eaafe1c802633bbd94849b9ab73926f0ba46cdae91629\"\n }\n ]\n}",
"registryId": "123456789012",
"repositoryName": "testrepo"
}
},
"sourceIPAddress": "203.0.113.12",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:user/Mary_Major",
"principalId": "AIDACKCEVSQ6C2EXAMPLE:account_name",
"sessionContext": {
"attributes": {
"creationDate": "2019-04-15T16:42:14Z",
"mfaAuthenticated": "false"
}
},
"type": "IAMUser",
"userName": "Mary_Major"
}
}
AWS ELB SSL Policies
#Ensures that deprecated TLS versions are not supported in internet-facing load balancers
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
# https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html#describe-ssl-policies
# Requirements to be "safe": 1) TLSv1.2+ only; 2) Forward Secrecy
TLS_SAFE_POLICIES = {
"ELBSecurityPolicy-FS-1-2-2019-08",
"ELBSecurityPolicy-FS-1-2-Res-2019-08",
"ELBSecurityPolicy-FS-1-2-Res-2020-10",
"ELBSecurityPolicy-TLS13-1-2-2021-06",
"ELBSecurityPolicy-TLS13-1-2-Res-2021-06",
"ELBSecurityPolicy-TLS13-1-3-2021-06",
}
def policy(resource):
# Ignore load balancers that aren't serving internet traffic
if resource.get("Scheme") == "internal":
return True
return len(resource.get("Listeners") if resource.get("Listeners") else []) >= 1 and all(
(each_policy in TLS_SAFE_POLICIES for each_policy in resource.get("SSLPolicies", {}).keys())
)
Rule specification
AnalysisType: policy
Filename: aws_alb_ssl_policy.py
PolicyID: "AWS.ELBv2.SSLPolicy"
DisplayName: "AWS ELB SSL Policies"
Enabled: true
ResourceTypes:
- AWS.ELBV2.ApplicationLoadBalancer
Tags:
- AWS
- Panther
- Initial Access:Exploit Public-Facing Application
Reports:
PCI:
- 2.2.3
- 4.1
MITRE ATT&CK:
- TA0001:T1190
Severity: Medium
Description: Ensures that deprecated TLS versions are not supported in internet-facing load balancers
Runbook: >
Update your load balancer's SSLPolicies to only support modern TLS versions
Reference: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html
Stages and Predicates
Flags AWS.ELBV2.ApplicationLoadBalancer resources when the condition below holds.
Condition
Schemeis notinternal
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Scheme | eq | internal | excludes:Scheme field:"Scheme" value:"internal" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Scheme | ne |
| field:"Scheme" kind:ne value:"internal" |
Response runbook
Update your load balancer's SSLPolicies to only support modern TLS versions
AWS Enforces SSL Policies
#This policy validates that ELBV2 load balancer listeners are using an SSL policy.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
def policy(resource):
# Casting to Bool as this may be None and we cannot return a NoneType
return bool(resource["SSLPolicies"])
Rule specification
AnalysisType: policy
Filename: aws_elbv2_load_balancer_has_ssl_policy.py
PolicyID: "AWS.ELBV2.LoadBalancer.HasSSLPolicy"
DisplayName: "AWS Enforces SSL Policies"
Enabled: false
ResourceTypes:
- AWS.ELBV2.ApplicationLoadBalancer
Tags:
- AWS
- PCI
- Initial Access:Exploit Public-Facing Application
Reports:
PCI:
- 2.2.3
- 4.1
MITRE ATT&CK:
- TA0001:T1190
Severity: Medium
Description: This policy validates that ELBV2 load balancer listeners are using an SSL policy.
Runbook: Apply an SSL policy to the listener to force HTTPS.
Reference: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-update-certificates.html#update-security-policy
Stages and Predicates
Flags AWS.ELBV2.ApplicationLoadBalancer resources when the condition below holds.
Condition
SSLPoliciesis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
SSLPolicies | is_not_null | excludes:SSLPolicies |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
SSLPolicies | is_null | field:"SSLPolicies" kind:is_null |
Response runbook
Apply an SSL policy to the listener to force HTTPS.
AWS GuardDuty Critical Severity Finding
#Detects critical-severity findings (9.0/9.0) from AWS GuardDuty indicating active compromise, imminent data loss, or ongoing attacks. GuardDuty uses machine learning and threat intelligence to identify compromised credentials, cryptocurrency mining, data exfiltration, and connections to malicious infrastructure. This rule filters out sample data and alerts only on genuine critical threats requiring immediate investigation.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
from panther_aws_helpers import aws_guardduty_context
def rule(event):
if event.deep_get("service", "additionalInfo", "sample"):
# in case of sample data
# https://docs.aws.amazon.com/guardduty/latest/ug/sample_findings.html
return False
return 9.0 <= float(event.get("severity", 0)) <= 10.0
def title(event):
return event.get("title")
def alert_context(event):
return aws_guardduty_context(event)
Rule specification
AnalysisType: rule
Filename: aws_guardduty_critical_sev_findings.py
RuleID: "AWS.GuardDuty.CriticalSeverityFinding"
DisplayName: "AWS GuardDuty Critical Severity Finding"
Enabled: true
LogTypes:
- AWS.GuardDuty
Tags:
- AWS
- Threat Detection
- Automated Security
- Anomaly Detection
- GuardDuty
- Machine Learning
Severity: Critical
Reports:
MITRE ATT&CK:
- TA0001:T1078
DedupPeriodMinutes: 15
Description: >
Detects critical-severity findings (9.0/9.0) from AWS GuardDuty indicating active compromise, imminent data loss, or ongoing attacks. GuardDuty uses machine learning and threat intelligence to identify compromised credentials, cryptocurrency mining, data exfiltration, and connections to malicious infrastructure. This rule filters out sample data and alerts only on genuine critical threats requiring immediate investigation.
Runbook: |
1. Look up the finding type in AWS GuardDuty documentation at https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_finding-types-active.html, then query CloudTrail for all API calls by the affected principal or resource between service.eventFirstSeen and service.eventLastSeen
2. For AttackSequence findings, review the description field to identify all MITRE ATT&CK tactics, techniques, and sensitive APIs called, then search for each of these API calls in CloudTrail to understand the full attack chain
3. Search VPC Flow Logs and S3 access logs for connections from the source IPs mentioned in the GuardDuty finding during the timeframe to identify data exfiltration, lateral movement, or persistence mechanisms
Reference: https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_findings.html#guardduty_findings-severity
SummaryAttributes:
- severity
- type
- title
- p_any_domain_names
- p_any_aws_arns
- p_any_aws_account_ids
Stages and Predicates
Fires on AWS.GuardDuty events when all of the conditions below hold.
Condition
service.additionalInfo.sampleis emptyseverityis at least9.0severityis at most10.0
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
description |
severity |
id |
type |
resource |
service |
accountId |
title |
Response runbook
1. Look up the finding type in AWS GuardDuty documentation at https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_finding-types-active.html, then query CloudTrail for all API calls by the affected principal or resource between service.eventFirstSeen and service.eventLastSeen
2. For AttackSequence findings, review the description field to identify all MITRE ATT&CK tactics, techniques, and sensitive APIs called, then search for each of these API calls in CloudTrail to understand the full attack chain
3. Search VPC Flow Logs and S3 access logs for connections from the source IPs mentioned in the GuardDuty finding during the timeframe to identify data exfiltration, lateral movement, or persistence mechanisms
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"accountId": "123456789012",
"arn": "arn:aws:guardduty:us-east-1:123456789012:detector/111111bbbbbbbbbb5555555551111111/finding/8c258d654378414b9bc26f61a93eb816",
"createdAt": "2025-02-25 20:40:06.721",
"description": "A sequence of actions involving 4 signals indicating a possible credential compromise was observed for IAMUser/john_doe with principalId AIDA3UBBJ2K3TVEXAMPLE in account 111122223333\nbetween eventFirstSeen and eventLastSeen with the following behaviors:\n - 5 MITRE ATT&CK tactics observed: Persistence, Privilege Escalation, Defense Evasion, Discovery, Initial Access\n - 5 MITRE ATT&CK techniques observed: T1562.008 - Impair Defenses: Disable or Modify Cloud Logs, T1098.003 - Account Manipulation: Additional Cloud Roles, T1078.004 - Valid Accounts: Cloud Accounts, T1087.004 - Account Discovery: Cloud Account, T1098 - Account Manipulation\n - Connected from a known Tor Exit Node: 10.0.0.1\n - 4 sensitive APIs called: cloudtrail:DeleteTrail, iam:AttachRolePolicy, iam:CreateRole, iam:ListUsers\n",
"id": "8c258d654378414b9bc26f61a93eb816",
"partition": "aws",
"region": "us-east-1",
"resource": {
"resourceType": "AttackSequence"
},
"schemaVersion": "2.0",
"service": {
"additionalInfo": {},
"archived": false,
"count": 1,
"detectorId": "111111bbbbbbbbbb5555555551111111",
"eventFirstSeen": "2025-02-25 20:40:06.000000000",
"eventLastSeen": "2025-02-25 20:40:06.000000000",
"featureName": "Correlation",
"resourceRole": "TARGET",
"serviceName": "guardduty"
},
"severity": 9,
"title": "Potential credential compromise of IAMUser/john_doe indicated by a sequence of actions.",
"type": "AttackSequence:IAM/CompromisedCredentials",
"updatedAt": "2025-02-25 20:40:06.721"
}
AWS GuardDuty Enabled
#GuardDuty is a threat detection service that continuously monitors for malicious activity and unauthorized behavior.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
# Add/remove regions as desired
REGIONS_REQUIRED = {
"us-west-2",
}
def policy(resource):
# Detector IDs are in the following format:
# [AccountId]:[Region]:AWS.GuardDuty.Detector
# so we grab the middle part to determine what regions have GuardDuty enabled
regions_enabled = [detector.split(":")[1] for detector in resource["Detectors"]]
for region in REGIONS_REQUIRED:
if region not in regions_enabled:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_guardduty_enabled.py
PolicyID: "AWS.GuardDuty.Enabled"
DisplayName: "AWS GuardDuty Enabled"
Enabled: true
ResourceTypes:
- AWS.GuardDuty.Detector.Meta
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
- Configuration Required
Reports:
MITRE ATT&CK:
- TA0005:T1562
Severity: High
Description: >
GuardDuty is a threat detection service that continuously monitors for malicious activity and unauthorized behavior.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-guardduty-is-enabled
Reference: https://aws.amazon.com/guardduty/
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-guardduty-is-enabled
AWS GuardDuty High Severity Finding
#A high-severity GuardDuty finding has been identified.
Detection logic
from panther_aws_helpers import aws_guardduty_context
def rule(event):
if event.deep_get("service", "additionalInfo", "sample"):
# in case of sample data
# https://docs.aws.amazon.com/guardduty/latest/ug/sample_findings.html
return False
return 7.0 <= float(event.get("severity", 0)) <= 8.9
def title(event):
return event.get("title")
def alert_context(event):
return aws_guardduty_context(event)
Rule specification
AnalysisType: rule
Filename: aws_guardduty_high_sev_findings.py
RuleID: "AWS.GuardDuty.HighSeverityFinding"
DisplayName: "AWS GuardDuty High Severity Finding"
Enabled: true
LogTypes:
- AWS.GuardDuty
Tags:
- AWS
Severity: High
DedupPeriodMinutes: 60
Description: >
A high-severity GuardDuty finding has been identified.
Runbook: >
Search related logs to understand the root cause of the activity.
Search the Panther Summary Attribute type value in https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_finding-types-active.html for additional details.
Reference: https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_findings.html#guardduty_findings-severity
SummaryAttributes:
- severity
- type
- title
- p_any_domain_names
- p_any_aws_arns
- p_any_aws_account_ids
Stages and Predicates
Fires on AWS.GuardDuty events when all of the conditions below hold.
Condition
service.additionalInfo.sampleis emptyseverityis at least7.0severityis at most8.9
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
description |
severity |
id |
type |
resource |
service |
accountId |
title |
Response runbook
Search related logs to understand the root cause of the activity. Search the Panther Summary Attribute type value in https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_finding-types-active.html for additional details.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"accountId": "123456789012",
"arn": "arn:aws:guardduty:us-west-2:123456789012:detector/111111bbbbbbbbbb5555555551111111/finding/90b82273685661b9318f078d0851fe9a",
"createdAt": "2020-02-14T18:12:22.316Z",
"description": "Principal AssumedRole:IAMRole attempted to add a highly permissive policy to themselves.",
"id": "eeb88ab56556eb7771b266670dddee5a",
"partition": "aws",
"region": "us-east-1",
"schemaVersion": "2.0",
"service": {
"action": {
"actionType": "AWS_API_CALL",
"awsApiCallAction": {
"affectedResources": {
"AWS::IAM::Role": "arn:aws:iam::123456789012:role/IAMRole"
},
"api": "PutRolePolicy",
"callerType": "Domain",
"domainDetails": {
"domain": "cloudformation.amazonaws.com"
},
"serviceName": "iam.amazonaws.com"
}
},
"additionalInfo": {},
"archived": false,
"count": 1,
"detectorId": "111111bbbbbbbbbb5555555551111111",
"eventFirstSeen": "2020-02-14T17:59:17Z",
"eventLastSeen": "2020-02-14T17:59:17Z",
"evidence": null,
"resourceRole": "TARGET",
"serviceName": "guardduty"
},
"severity": 8,
"title": "Principal AssumedRole:IAMRole attempted to add a policy to themselves that is highly permissive.",
"type": "PrivilegeEscalation:IAMUser/AdministrativePermissions",
"updatedAt": "2020-02-14T18:12:22.316Z"
}
AWS GuardDuty Low Severity Finding
#A low-severity GuardDuty finding has been identified.
Detection logic
from panther_aws_helpers import aws_guardduty_context
def rule(event):
if event.deep_get("service", "additionalInfo", "sample"):
# in case of sample data
# https://docs.aws.amazon.com/guardduty/latest/ug/sample_findings.html
return False
return 0.1 <= float(event.get("severity", 0)) <= 3.9
def title(event):
return event.get("title")
def alert_context(event):
return aws_guardduty_context(event)
Rule specification
AnalysisType: rule
Filename: aws_guardduty_low_sev_findings.py
RuleID: "AWS.GuardDuty.LowSeverityFinding"
DisplayName: "AWS GuardDuty Low Severity Finding"
Enabled: true
LogTypes:
- AWS.GuardDuty
Tags:
- AWS
Severity: Low
DedupPeriodMinutes: 1440 # 24 hours
Description: >
A low-severity GuardDuty finding has been identified.
Runbook: >
Search related logs to understand the root cause of the activity.
Search the Panther Summary Attribute type value in https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_finding-types-active.html for additional details.
Reference: https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_findings.html#guardduty_findings-severity
SummaryAttributes:
- severity
- type
- title
- p_any_domain_names
- p_any_aws_arns
- p_any_aws_account_ids
Stages and Predicates
Fires on AWS.GuardDuty events when all of the conditions below hold.
Condition
service.additionalInfo.sampleis emptyseverityis at least0.1severityis at most3.9
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
description |
severity |
id |
type |
resource |
service |
accountId |
title |
Response runbook
Search related logs to understand the root cause of the activity. Search the Panther Summary Attribute type value in https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_finding-types-active.html for additional details.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"accountId": "123456789012",
"arn": "arn:aws:guardduty:us-west-2:123456789012:detector/111111bbbbbbbbbb5555555551111111/finding/90b82273685661b9318f078d0851fe9a",
"createdAt": "2020-02-14T18:12:22.316Z",
"description": "Principal AssumedRole:IAMRole attempted to add a highly permissive policy to themselves.",
"id": "eeb88ab56556eb7771b266670dddee5a",
"partition": "aws",
"region": "us-east-1",
"schemaVersion": "2.0",
"service": {
"action": {
"actionType": "AWS_API_CALL",
"awsApiCallAction": {
"affectedResources": {
"AWS::IAM::Role": "arn:aws:iam::123456789012:role/IAMRole"
},
"api": "PutRolePolicy",
"callerType": "Domain",
"domainDetails": {
"domain": "cloudformation.amazonaws.com"
},
"serviceName": "iam.amazonaws.com"
}
},
"additionalInfo": {},
"archived": false,
"count": 1,
"detectorId": "111111bbbbbbbbbb5555555551111111",
"eventFirstSeen": "2020-02-14T17:59:17Z",
"eventLastSeen": "2020-02-14T17:59:17Z",
"evidence": null,
"resourceRole": "TARGET",
"serviceName": "guardduty"
},
"severity": 1,
"title": "Principal AssumedRole:IAMRole attempted to add a policy to themselves that is highly permissive.",
"type": "PrivilegeEscalation:IAMUser/AdministrativePermissions",
"updatedAt": "2020-02-14T18:12:22.316Z"
}
AWS GuardDuty Master Account
#Ensure that all GuardDuty logs are sending into a single Master account. This is a best practice for centralizing detection logic and useful data during an investigation.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
from panther_base_helpers import deep_get
# Set the MASTER_ACCOUNT_ID variable to a string
# of the AWS Master Account receiving GuardDuty logs.
MASTER_ACCOUNT_ID = None
def policy(resource):
if MASTER_ACCOUNT_ID is None:
return True
if resource["Master"] is None:
return False
return MASTER_ACCOUNT_ID == deep_get(resource, "Master", "AccountId")
Rule specification
AnalysisType: policy
Filename: aws_guardduty_master_account.py
PolicyID: "AWS.GuardDuty.MasterAccount"
DisplayName: "AWS GuardDuty Master Account"
Enabled: false
ResourceTypes:
- AWS.GuardDuty.Detector
Tags:
- AWS
- Configuration Required
- Security Control
- Defense Evasion:Impair Defenses
Reports:
MITRE ATT&CK:
- TA0005:T1562
Severity: High
Description: >
Ensure that all GuardDuty logs are sending into a single Master account. This is a best practice for centralizing detection logic and useful data during an investigation.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-guardduty-is-logging-to-a-master-account
Reference: https://aws.amazon.com/guardduty/
Stages and Predicates
Flags AWS.GuardDuty.Detector resources when the condition below holds.
Condition
Masteris empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Master | is_not_null | excludes:Master |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Master | is_null | field:"Master" kind:is_null |
Response runbook
AWS GuardDuty Medium Severity Finding
#A medium-severity GuardDuty finding has been identified.
Detection logic
from panther_aws_helpers import aws_guardduty_context
def rule(event):
if event.deep_get("service", "additionalInfo", "sample"):
# in case of sample data
# https://docs.aws.amazon.com/guardduty/latest/ug/sample_findings.html
return False
return 4.0 <= float(event.get("severity", 0)) <= 6.9
def title(event):
return event.get("title")
def alert_context(event):
return aws_guardduty_context(event)
Rule specification
AnalysisType: rule
Filename: aws_guardduty_med_sev_findings.py
RuleID: "AWS.GuardDuty.MediumSeverityFinding"
DisplayName: "AWS GuardDuty Medium Severity Finding"
Enabled: true
LogTypes:
- AWS.GuardDuty
Tags:
- AWS
Severity: Medium
DedupPeriodMinutes: 480 # 8 hours
Description: >
A medium-severity GuardDuty finding has been identified.
Runbook: >
Search related logs to understand the root cause of the activity.
Search the Panther Summary Attribute type value in https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_finding-types-active.html for additional details.
Reference: https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_findings.html#guardduty_findings-severity
SummaryAttributes:
- severity
- type
- title
- p_any_domain_names
- p_any_aws_arns
- p_any_aws_account_ids
Stages and Predicates
Fires on AWS.GuardDuty events when all of the conditions below hold.
Condition
service.additionalInfo.sampleis emptyseverityis at least4.0severityis at most6.9
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
description |
severity |
id |
type |
resource |
service |
accountId |
title |
Response runbook
Search related logs to understand the root cause of the activity. Search the Panther Summary Attribute type value in https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_finding-types-active.html for additional details.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"accountId": "123456789012",
"arn": "arn:aws:guardduty:us-west-2:123456789012:detector/111111bbbbbbbbbb5555555551111111/finding/90b82273685661b9318f078d0851fe9a",
"createdAt": "2020-02-14T18:12:22.316Z",
"description": "Principal AssumedRole:IAMRole attempted to add a highly permissive policy to themselves.",
"id": "eeb88ab56556eb7771b266670dddee5a",
"partition": "aws",
"region": "us-east-1",
"schemaVersion": "2.0",
"service": {
"action": {
"actionType": "AWS_API_CALL",
"awsApiCallAction": {
"affectedResources": {
"AWS::IAM::Role": "arn:aws:iam::123456789012:role/IAMRole"
},
"api": "PutRolePolicy",
"callerType": "Domain",
"domainDetails": {
"domain": "cloudformation.amazonaws.com"
},
"serviceName": "iam.amazonaws.com"
}
},
"additionalInfo": {},
"archived": false,
"count": 1,
"detectorId": "111111bbbbbbbbbb5555555551111111",
"eventFirstSeen": "2020-02-14T17:59:17Z",
"eventLastSeen": "2020-02-14T17:59:17Z",
"evidence": null,
"resourceRole": "TARGET",
"serviceName": "guardduty"
},
"severity": 5,
"title": "Principal AssumedRole:IAMRole attempted to add a policy to themselves that is highly permissive.",
"type": "PrivilegeEscalation:IAMUser/AdministrativePermissions",
"updatedAt": "2020-02-14T18:12:22.316Z"
}
AWS IAM Access Key Compromise Detection
#This alert occurs when AWS has detected exposed credentials. It attaches a policy to deny certain actions, effectively quarantining those credentials, and is accompanied by a support case with instructions for detaching the policy.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWSCloudTrail - Privilege escalation via CloudFormation policy (Kusto)
- AWSCloudTrail - Privilege escalation via CRUD DynamoDB policy (Kusto)
- AWSCloudTrail - Privilege escalation via CRUD IAM policy (Kusto)
- AWSCloudTrail - Privilege escalation via CRUD KMS policy (Kusto)
- AWSCloudTrail - Privilege escalation via CRUD Lambda policy (Kusto)
- AWSCloudTrail - Privilege escalation via CRUD S3 policy (Kusto)
- AWSCloudTrail - Privilege escalation via DataPipeline policy (Kusto)
- AWSCloudTrail - Privilege escalation via EC2 policy (Kusto)
Detection logic
from panther_aws_helpers import aws_rule_context
EXPOSED_CRED_POLICIES = {
"AWSExposedCredentialPolicy_DO_NOT_REMOVE",
"AWSCompromisedKeyQuarantine",
"AWSCompromisedKeyQuarantineV2",
"AWSCompromisedKeyQuarantineV3",
}
def rule(event):
if event.get("eventName") != "PutUserPolicy":
return False
request_params = event.get("requestParameters") or {}
if request_params.get("policyName") not in EXPOSED_CRED_POLICIES:
return False
return True
def title(event):
user_name = event.deep_get("userIdentity", "userName")
access_key_id = event.deep_get("userIdentity", "accessKeyId")
return (
f"[{user_name}]'s AWS IAM Access Key ID [{access_key_id}]"
f" was exposed and quarantined by AWS"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_key_compromised.py
RuleID: "AWS.IAM.AccessKeyCompromised"
DisplayName: "AWS IAM Access Key Compromise Detection"
Enabled: true
LogTypes:
- AWS.CloudTrail
Reports:
MITRE ATT&CK:
- TA0006:T1552
Tags:
- AWS
- Credential Access:Unsecured Credentials
Severity: High
Description: This alert occurs when AWS has detected exposed credentials. It attaches a policy to deny certain actions, effectively quarantining those credentials, and is accompanied by a support case with instructions for detaching the policy.
Runbook: Determine the IAM user who owns the key, rotate/disable/delete the key, and then investigate which actions were taken by the compromised key to determine scope and if any remediation is needed.
Reference: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisPutUserPolicyrequestParameters.policyNameis one ofAWSExposedCredentialPolicy_DO_NOT_REMOVE,AWSCompromisedKeyQuarantine,AWSCompromisedKeyQuarantineV2,AWSCompromisedKeyQuarantineV3
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"PutUserPolicy" |
requestParameters.policyName | in |
| field:"requestParameters.policyName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
userName | userIdentity.userName |
accessKeyId | userIdentity.accessKeyId |
Response runbook
Determine the IAM user who owns the key, rotate/disable/delete the key, and then investigate which actions were taken by the compromised key to determine scope and if any remediation is needed.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "1c2a53d1-58cc-41b3-85b8-bd7565370e0d",
"eventName": "PutUserPolicy",
"eventSource": "iam.amazonaws.com",
"eventTime": "2020-04-10T06:22:08Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "27ca92a5-61cc-44aa-b875-042a25310064",
"requestParameters": {
"policyDocument": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"Stmt1538161409\",\"Effect\":\"Deny\",\"Action\":[\"lambda:CreateFunction\",\"iam:AttachUserPolicy\",\"iam:PutUserPolicy\",\"organizations:InviteAccountToOrganization\",\"ec2:RunInstances\",\"iam:DetachUserPolicy\",\"iam:CreateUser\",\"lightsail:Create*\",\"lightsail:Update*\",\"ec2:StartInstances\",\"ec2:RequestSpotInstances\",\"iam:ChangePassword\",\"iam:CreateLoginProfile\",\"organizations:CreateOrganization\",\"organizations:CreateAccount\",\"lightsail:Delete*\",\"iam:AttachGroupPolicy\",\"iam:CreateAccessKey\",\"iam:UpdateUser\",\"iam:UpdateAccountPasswordPolicy\",\"iam:DeleteUserPolicy\",\"iam:PutUserPermissionsBoundary\",\"iam:UpdateAccessKey\",\"lightsail:DownloadDefaultKeyPair\",\"iam:CreateInstanceProfile\",\"lightsail:Start*\",\"lightsail:GetInstanceAccessDetails\",\"iam:CreateRole\",\"iam:PutGroupPolicy\",\"iam:AttachRolePolicy\"],\"Resource\":[\"*\"]}]}",
"policyName": "AWSCompromisedKeyQuarantineV3",
"userName": "compromised_user"
},
"responseElements": null,
"sourceIPAddress": "72.21.217.97",
"userAgent": "aws-internal/3 aws-sdk-java/1.11.706 Linux/4.9.184-0.1.ac.235.83.329.metal1.x86_64 OpenJDK_64-Bit_Server_VM/25.242-b08 java/1.8.0_242 vendor/Oracle_Corporation",
"userIdentity": {
"accessKeyId": "XXXXXXXXXXXXXXXXXXXXX",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/compromised_user",
"principalId": "XXXXXXXXXXXXXXXXXXX",
"type": "IAMUser",
"userName": "compromised_user"
}
}
AWS IAM Group Read Only Events
#This rule captures multiple read/list events related to IAM group management in AWS Cloudtrail.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
# arn allow list to suppress alerts
ARN_ALLOW_LIST = []
GROUP_ACTIONS = [
"GetGroup",
"GetGroupPolicy",
"ListAttachedGroupPolicies",
"ListGroupPolicies",
"ListGroups",
"ListGroupsForUser",
]
def rule(event):
event_arn = event.get("userIdentity", {}).get("arn", "<NO_ARN_FOUND>")
# Return True if arn not in whitelist and event source is iam and event name is
# present in read/list event_name list.
if (
event_arn not in ARN_ALLOW_LIST
and event.get("eventSource", "<NO_EVENT_SOURCE_FOUND>") == "iam.amazonaws.com"
and event.get("eventName", "<NO_EVENT_NAME_FOUND>") in GROUP_ACTIONS
):
# continue on with analysis
return True
return False
def title(event):
return (
f"{event.get('userIdentity',{}).get('arn','<NO_ARN_FOUND>')} "
f"IAM user group activity event found: {event.get('eventName', '<NO_EVENT_NAME_FOUND>')} "
f"in account {event.get('recipientAccountId', '<NO_RECIPIENT_ACCT_ID_FOUND>')} "
f"in region {event.get('awsRegion', '<NO_AWS_REGION_FOUND>')}."
)
def dedup(event):
# dedup via arn value
return f"{event.get('userIdentity',{}).get('arn','<NO_ARN_FOUND>')}"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: This rule captures multiple read/list events related to IAM group management in AWS Cloudtrail.
DisplayName: "AWS IAM Group Read Only Events"
Enabled: false
Filename: aws_iam_group_read_only_events.py
Reference: https://attack.mitre.org/techniques/T1069/
Runbook: Examine other activities done by this user to determine whether or not activity is suspicious.
Severity: Info
CreateAlert: false
Tags:
- AWS
- Cloudtrail
- Configuration Required
- IAM
- MITRE
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.IAM.Group.Read.Only.Events"
Threshold: 2
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
userIdentity.arnis not one ofeventSourceisiam.amazonaws.comeventNameis one ofGetGroup,GetGroupPolicy,ListAttachedGroupPolicies,ListGroupPolicies,ListGroups
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
userIdentity.arn | in | excludes:userIdentity.arn |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"iam.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Response runbook
Examine other activities done by this user to determine whether or not activity is suspicious.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "883efb94-aa58-4512-beb7-10a5fffa33e4",
"eventName": "GetGroup",
"eventSource": "iam.amazonaws.com",
"eventTime": "2022-12-11 19:42:55",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": true,
"recipientAccountId": "1231231234",
"requestID": "f92dd1a7-ad07-4fef-9511-1081d2dd3585",
"requestParameters": {
"maxItems": 1000,
"userName": "user-name"
},
"sourceIPAddress": "cloudformation.amazonaws.com",
"userAgent": "cloudformation.amazonaws.com",
"userIdentity": {
"accessKeyId": "ASIAVKVYIOO7BDL4T5NG",
"accountId": "1231231234",
"arn": "arn:aws:sts::1231231234:assumed-role/AssumedRole-us-east-2/123123123456",
"invokedBy": "cloudformation.amazonaws.com",
"principalId": "AROAVKVYIOO7JN7TN7NSA:123123123456",
"sessionContext": {
"attributes": {
"creationDate": "2022-12-11T19:42:54Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "1231231234",
"arn": "arn:aws:iam::1231231234:role/PAssumedRole-us-east-2",
"principalId": "AROAVKVYIOO7JN7TN7NSA",
"type": "Role",
"userName": "AssumedRole-us-east-2"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS IAM Group Users
#This Policy ensures that all IAM groups have at least one IAM user. If they are vacant, they should be deleted.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
def policy(resource):
return resource["Users"] is not None
Rule specification
AnalysisType: policy
Filename: aws_iam_group_users.py
PolicyID: "AWS.IAM.Group.Users"
DisplayName: "AWS IAM Group Users"
Enabled: true
ResourceTypes:
- AWS.IAM.Group
Tags:
- AWS
- Identity & Access Management
- Privilege Escalation:Valid Accounts
Reports:
MITRE ATT&CK:
- TA0004:T1078
Severity: Low
Description: >
This Policy ensures that all IAM groups have at least one IAM user. If they are vacant,
they should be deleted.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-iam-group-has-users
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html
Stages and Predicates
Flags AWS.IAM.Group resources when the condition below holds.
Condition
Usersis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Users | is_not_null | excludes:Users |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Users | is_null | field:"Users" kind:is_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-iam-group-has-users
AWS IAM Password Unused
#This policy validates IAM users with console passwords have logged in within the past 90 days.
Detection logic
import datetime
from panther_base_helpers import resolve_timestamp_string
TIMEOUT_DAYS = datetime.timedelta(days=90)
DEFAULT_TIME = "0001-01-01T00:00:00Z"
def aged_out(timestamp):
datetime_ts = resolve_timestamp_string(timestamp)
if not datetime_ts:
return False
return (datetime.datetime.now() - datetime_ts) > TIMEOUT_DAYS
def policy(resource):
# If a user is less than 4 hours old, it may not have a credential report generated yet.
# It will be re-scanned periodically until a credential report is found, at which point this
# policy will be properly evaluated.
report = resource.get("CredentialReport")
if not report:
return True
if report.get("PasswordEnabled"):
if report.get("PasswordLastUsed") != DEFAULT_TIME and aged_out(
report.get("PasswordLastUsed")
):
return False
if report.get("PasswordLastUsed") == DEFAULT_TIME and aged_out(
report.get("PasswordLastChanged")
):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_password_unused.py
PolicyID: "AWS.Password.Unused"
DisplayName: "AWS IAM Password Unused"
Enabled: true
ResourceTypes:
- AWS.IAM.User
Tags:
- AWS
- Identity & Access Management
Reports:
CIS:
- 1.3
PCI:
- 8.1.4
Severity: Low
Description: >
This policy validates IAM users with console passwords have logged in within the past 90 days.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-password-used-every-90-days
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html
Stages and Predicates
Flags AWS.IAM.User resources when the condition below holds.
Condition
CredentialReportis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
CredentialReport | is_null | excludes:CredentialReport |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
CredentialReport | is_not_null | field:"CredentialReport" kind:is_not_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-password-used-every-90-days
AWS IAM Policy Administrative Privileges
#This policy validates that there are no IAM policies that grant full administrative privileges to IAM users or groups.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
import json
from panther_base_helpers import listify
def policy(resource):
iam_policy = json.loads(resource["PolicyDocument"])
statements = listify(iam_policy["Statement"])
for state in statements:
actions = listify(state.get("Action", []))
resources = listify(state.get("Resource", []))
if state["Effect"] == "Allow" and "*" in actions and "*" in resources:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_iam_policy_administrative_privileges.py
PolicyID: "AWS.IAM.Policy.AdministrativePrivileges"
DisplayName: "AWS IAM Policy Administrative Privileges"
Enabled: true
ResourceTypes:
- AWS.IAM.Policy
Tags:
- AWS
- Identity & Access Management
- Privilege Escalation:Valid Accounts
Reports:
CIS:
- 1.22
MITRE ATT&CK:
- TA0004:T1078
Severity: High
Description: >
This policy validates that there are no IAM policies that grant full administrative
privileges to IAM users or groups.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-iam-policy-does-not-grant-full-administrative-privileges
Reference: >
https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
AWS IAM Policy Assigned to User
#This policy validates that there are no IAM policies assigned directly to users. Best practice suggests assigning to an IAM group and placing users within that group.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
def policy(resource):
return resource["InlinePolicies"] is None and resource["ManagedPolicyNames"] is None
Rule specification
AnalysisType: policy
Filename: aws_iam_policy_assigned_to_user.py
PolicyID: "AWS.IAM.Policy.AssignedToUser"
DisplayName: "AWS IAM Policy Assigned to User"
Enabled: true
ResourceTypes:
- AWS.IAM.User
Tags:
- AWS
- Identity & Access Management
- Privilege Escalation:Valid Accounts
Reports:
CIS:
- 1.16
PCI:
- 2.2.4
- 7.1.1
MITRE ATT&CK:
- TA0004:T1078
Severity: Low
Description: >
This policy validates that there are no IAM policies assigned directly to users.
Best practice suggests assigning to an IAM group and placing users within that group.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-iam-policy-not-assigned-directly-to-user
Reference: https://amzn.to/2Pss7Ln
Stages and Predicates
Flags AWS.IAM.User resources when any of the conditions below holds.
Condition
any of:
InlinePoliciesis presentManagedPolicyNamesis present
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
InlinePolicies | is_null | excludes:InlinePolicies | |
ManagedPolicyNames | is_null | excludes:ManagedPolicyNames |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
InlinePolicies | is_not_null | field:"InlinePolicies" kind:is_not_null | |
ManagedPolicyNames | is_not_null | field:"ManagedPolicyNames" kind:is_not_null |
Response runbook
AWS IAM Policy Blocklist
#This detects the usage of highly permissive IAM Policies that should only be assigned to a small number of users, roles, or groups.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
# IAM policy ARN's list in this variable will be checked against the policies assigned to all IAM
# resources (users, groups, roles) and this rule will fail if any policy ARN in the blocklist is
# assigned to a user
IAM_POLICY_ARN_BLOCKLIST = [
"TEST_BLOCKLISTED_ARN",
]
def policy(resource):
# Check if the IAM resource has any managed policies
if resource["ManagedPolicyNames"] is None:
return True
# Iterate through the blocklist and return true if the resource is in violation
for iam_policy in IAM_POLICY_ARN_BLOCKLIST:
if iam_policy in resource["ManagedPolicyNames"]:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_iam_policy_blocklist.py
PolicyID: "AWS.IAM.Policy.Blacklist"
DisplayName: "AWS IAM Policy Blocklist"
Enabled: false
ResourceTypes:
- AWS.IAM.Group
- AWS.IAM.Role
- AWS.IAM.User
Reports:
MITRE ATT&CK:
- TA0004:T1078
Tags:
- AWS
- Configuration Required
- Identity & Access Management
- Privilege Escalation:Valid Accounts
Severity: Medium
Description: >
This detects the usage of highly permissive IAM Policies that should only be assigned to a small number of users, roles, or groups.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-iam-policy-blacklist-is-respected
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
Stages and Predicates
Flags AWS.IAM.Group, AWS.IAM.Role, AWS.IAM.User resources when the condition below holds.
Condition
ManagedPolicyNamesis present
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
ManagedPolicyNames | is_null | excludes:ManagedPolicyNames |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
ManagedPolicyNames | is_not_null | field:"ManagedPolicyNames" kind:is_not_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-iam-policy-blacklist-is-respected
AWS IAM Policy Does Not Grant Any Administrative Access
#This policy validates that no IAM policies grant admin access. This should be combined with suppressions on the legitimate IAM admin policies in your account so that it only fires when new and unexpected policies granting admin access are created.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
import json
from policyuniverse.expander_minimizer import expand_policy
from policyuniverse.policy import Policy
# White listed policies (e.g. the approved admin policies) can be specified here, or as an
# exception to this policy.
ADMIN_ACTIONS = {
"Permissions",
}
def policy(resource):
iam_policy = Policy(expand_policy(json.loads(resource["PolicyDocument"])))
action_summary = iam_policy.action_summary()
# Sometimes AWS Service Linked Roles+Policies violate the
# expectation as expressed in policyuniverse.
# service-linked roles have a path of /aws-service-role/
# service-linked roles and their policies are not editable
# service-role is editable, and so not included
if resource.get("Path", "") == "/aws-service-role/":
return True
# Check if the policy grants any administrative privileges
return not any(
ADMIN_ACTIONS.intersection(action_summary[service]) for service in action_summary
)
Rule specification
AnalysisType: policy
Filename: aws_iam_policy_does_not_grant_admin_access.py
PolicyID: "AWS.IAM.Policy.DoesNotGrantAdminAccess"
DisplayName: "AWS IAM Policy Does Not Grant Any Administrative Access"
Enabled: true
ResourceTypes:
- AWS.IAM.Policy
Tags:
- AWS
- PCI
- Security, Identity & Compliance
- Privilege Escalation:Valid Accounts
Reports:
PCI:
- 2.2.4
- 7.1.2
MITRE ATT&CK:
- TA0004:T1078
Severity: Medium
Description: >
This policy validates that no IAM policies grant admin access. This should be combined with suppressions on the legitimate IAM admin policies in your account so that it only fires when new and unexpected policies granting admin access are created.
Runbook: Delete the unapproved IAM admin policy.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
Stages and Predicates
Flags AWS.IAM.Policy resources when the condition below holds.
Condition
Pathis not/aws-service-role/
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Path | eq | /aws-service-role/ | excludes:Path field:"Path" value:"/aws-service-role/" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Path | ne |
| field:"Path" kind:ne value:"/aws-service-role/" |
Response runbook
Delete the unapproved IAM admin policy.
AWS IAM Policy Does Not Grant Network Admin Access
#This policy validates that no IAM policies grant admin privileges on network resources. This should be used in conjunction with suppressions for the legitimate network admin policies in your account.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
import json
from policyuniverse.action_categories import categories_for_actions
from policyuniverse.expander_minimizer import expand_policy
from policyuniverse.policy import Policy
# White listed policies (e.g. the approved network admin policy) can be specified here, or as an
# exception to this policy.
ADMIN_ACTIONS = {
"Tagging",
"Write",
}
NETWORK_RESOURCES = {
"vpc",
"securitygroup",
"networkacl",
"routetable",
}
def policy(resource):
iam_policy = Policy(expand_policy(json.loads(resource["PolicyDocument"])))
action_summary = iam_policy.action_summary()
#
# These first two checks can technically be skipped and this policy will still return correct
# results, but they prevent the more computationally expensive check the majority of the time.
#
# Check if the policy applies to EC2 resources
if "ec2" not in action_summary:
return True
# Sometimes AWS Service Linked Roles+Policies violate the
# expectation as expressed in policyuniverse.
# service-linked roles have a path of /aws-service-role/
# service-linked roles and their policies are not editable
# service-role is editable, and so not included
if resource.get("Path", "") == "/aws-service-role/":
return True
# Check if the policy grants administrative privileges
if not ADMIN_ACTIONS.intersection(action_summary["ec2"]):
return True
# Get the EC2 actions pertaining specifically to network resources
network_actions = set()
for statement in iam_policy.statements:
# Only check statements granting access
if statement.effect != "Allow":
continue
# Only check actions that are granted on network resources
for action in statement.actions:
if any(resource in action for resource in NETWORK_RESOURCES):
network_actions.add(action)
# For all actions that have been granted on network resources, ensure none grant admin access
network_actions_summary = categories_for_actions(network_actions)
return not any(action in ADMIN_ACTIONS for action in network_actions_summary["ec2"])
Rule specification
AnalysisType: policy
Filename: aws_iam_policy_does_not_grant_network_admin_access.py
PolicyID: "AWS.IAM.Policy.DoesNotGrantNetworkAdminAccess"
DisplayName: "AWS IAM Policy Does Not Grant Network Admin Access"
Enabled: true
ResourceTypes:
- AWS.IAM.Policy
Tags:
- AWS
- PCI
- Privilege Escalation:Valid Accounts
Reports:
PCI:
- 1.1.5
MITRE ATT&CK:
- TA0004:T1078
Severity: Medium
Description: >
This policy validates that no IAM policies grant admin privileges on network resources. This should be used in conjunction with suppressions for the legitimate network admin policies in your account.
Runbook: >
Delete the unapproved network admin policy.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
Stages and Predicates
Flags AWS.IAM.Policy resources when the condition below holds.
Condition
Pathis not/aws-service-role/
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Path | eq | /aws-service-role/ | excludes:Path field:"Path" value:"/aws-service-role/" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Path | ne |
| field:"Path" kind:ne value:"/aws-service-role/" |
Response runbook
Delete the unapproved network admin policy.
AWS IAM Policy Role Mapping
#This policy validates that policies that have been explicitly configured to be set to certain roles are still attached to those roles.
Detection logic
from panther_base_helpers import deep_get
# This is a mapping of what policies must be attached to what roles in the account.
# The mapping is keyed by string policy names to tuples of role names. Ordering doesn't matter.
# Example:
# POLICY_ROLE_MAPPINGS = {
# 'ExamplePolicyName1': ('ExampleRoleName1', 'ExampleRoleName2', 'ExampleRoleName3'),
# 'ExamplePolicyName2': ('ExampleRoleName1', 'ExampleRoleName3', 'ExampleRoleName4'),
# }
POLICY_ROLE_MAPPINGS = {
"TestPolicyName": ("TestRole1", "TestRole2"),
}
def policy(resource):
# Check if there are any required roles for this policy to be attached to
if resource["PolicyName"] not in POLICY_ROLE_MAPPINGS:
return True
# Check if this policy is attached to any roles
if deep_get(resource, "Entities", "PolicyRoles") is None:
return False
# Build the list of role names this policy is actually attached to
roles_attached = [
role["RoleName"] for role in deep_get(resource, "Entities", "PolicyRoles", default=[])
]
# For each required role, ensure that role has the policy attached
for role_needed in POLICY_ROLE_MAPPINGS[resource["PolicyName"]]:
if role_needed not in roles_attached:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_iam_policy_role_mapping.py
PolicyID: "AWS.IAM.Policy.RoleMapping"
DisplayName: "AWS IAM Policy Role Mapping"
Enabled: false
ResourceTypes:
- AWS.IAM.Policy
Tags:
- AWS
- Configuration Required
- Identity & Access Management
Severity: High
Description: >
This policy validates that policies that have been explicitly configured to be set to certain roles are still attached to those roles.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-iam-policy-role-mapping-is-respected
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html
Stages and Predicates
Flags AWS.IAM.Policy resources when the condition below holds.
Condition
PolicyNameis one ofTestPolicyName
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
PolicyName | eq | TestPolicyName | excludes:PolicyName field:"PolicyName" value:"TestPolicyName" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
PolicyName | in |
| field:"PolicyName" kind:in value:"TestPolicyName" |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-iam-policy-role-mapping-is-respected
AWS IAM Resource Does Not Have Inline Policy
#This policy validates that no IAM entities have inline policies assigned. Inline policies are more difficult to administer and audit, and may lead to access that lasts longer than intended.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Detection logic
def policy(resource):
return not resource["InlinePolicies"]
Rule specification
AnalysisType: policy
Filename: aws_iam_resource_does_not_have_inline_policy.py
PolicyID: "AWS.IAM.Resource.DoesNotHaveInlinePolicy"
DisplayName: "AWS IAM Resource Does Not Have Inline Policy"
Enabled: true
ResourceTypes:
- AWS.IAM.User
- AWS.IAM.Group
Tags:
- AWS
- PCI
- Persistence:Valid Accounts
Reports:
PCI:
- 2.2.4
- 7.1.1
MITRE ATT&CK:
- TA0003:T1078
Severity: Medium
Description: >
This policy validates that no IAM entities have inline policies assigned. Inline policies are more difficult to administer and audit, and may lead to access that lasts longer than intended.
Runbook: >
Delete the inline policy, and if the permissions are required add an appropriate IAM managed policy to the entity.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html
Stages and Predicates
Flags AWS.IAM.User, AWS.IAM.Group resources when the condition below holds.
Condition
InlinePoliciesis present
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
InlinePolicies | is_null | excludes:InlinePolicies |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
InlinePolicies | is_not_null | field:"InlinePolicies" kind:is_not_null |
Response runbook
Delete the inline policy, and if the permissions are required add an appropriate IAM managed policy to the entity.
AWS IAM Role Grants (permission) to Non-organizational Account
#This policy validates that IAM roles that grant the (specified) permission do not allow accounts outside the organization to assume them.
Detection logic
import json
from botocore.exceptions import NoCredentialsError
from panther_aws_helpers import BadLookup, resource_lookup
# This is a list of the account numbers included in the organization
# Example:
# accounts = [
# '123456789012',
# '123456789013'
# ]
# account *12 is used as an organizational account in built-in unit tests
# account *13 is used as a non-organizational account
accounts = [
"123456789012",
]
# The specific permission that the policy checks
# CONFIGURATION_REQUIRED: replace default "lambda:AddPermission" in unit tests
# with specified permission
PERMISSION = "lambda:AddPermission"
# CONFIGURATION_REQUIRED: modify policy to contain specified permission, above
mock_policy_has_permission = json.loads("""
{
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"lambda:AddPermission",
"lambda:GetPolicy"
],
"Resource": "*"
}
]
}
}
""")
mock_policy_no_permission = json.loads("""
{
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "lambda:ListAliases",
"Resource": "*"
}
]
}
}
""")
# check to see if the account that is granted permission is third party
def check_account(resource):
content_assumerole = resource.get("AssumeRolePolicyDocument")
if isinstance(content_assumerole, str):
content_assumerole = json.loads(content_assumerole)
principal = content_assumerole["Statement"][0]["Principal"]
if "AWS" in principal.keys():
if isinstance(principal["AWS"], list):
for principal_aws in principal["AWS"]:
if not check_account_number(principal_aws):
return False
else:
return check_account_number(principal["AWS"])
return True
def check_account_number(principal_aws):
if principal_aws.split(":")[4] not in accounts:
return False
return True
def check_policy(policy_text):
if isinstance(policy_text, str):
policy_text = json.loads(policy_text)
for statement in policy_text.get("Statement", []):
if PERMISSION in statement.get("Action", []):
return False
return True
def policy(resource):
# pylint: disable=too-complex
if not check_account(resource):
for policy_text in (resource.get("InlinePolicies") or {}).values():
if not check_policy(policy_text):
return False
for managed_policy_name in resource.get("ManagedPolicyNames") or []:
managed_policy_id = f"arn:aws:iam::{resource['AccountId']}:policy/{managed_policy_name}"
try:
# CONFIGURATION REQUIRED
# to mock a Managed Policy
# - create mock policy, above
# - add '“IsUnitTest": true,' to test resource in .yml
# - add an additional 'key: value' pair and 'if/elif' block for each
# mocked case to .yml
# Note: all Managed Policies in the test resource will be mocked
# comment out 'if, else' block to optimize for production use
if not resource.get("IsUnitTest"):
managed_policy = resource_lookup(managed_policy_id)
else:
if resource.get("HasPermission"):
managed_policy = mock_policy_has_permission
elif resource.get("DoesNotHavePermission"):
managed_policy = mock_policy_no_permission
else:
return True
# uncomment next line to optimize for production use
# managed_policy = resource_lookup(managed_policy_id)
except BadLookup:
return True
except NoCredentialsError:
return True
policy_text = managed_policy.get("PolicyDocument")
if not check_policy(policy_text):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_iam_role_external_permission.py
PolicyID: "AWS.IAM.Role.ExternalPermission"
DisplayName: "AWS IAM Role Grants (permission) to Non-organizational Account"
Enabled: false
ResourceTypes:
- AWS.IAM.Role
Tags:
- AWS
- Identity & Access Management
- Configuration Required
Severity: Critical
Description: >
This policy validates that IAM roles that grant the (specified) permission do not allow accounts outside the organization to assume them.
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
AWS IAM Role Restricts Usage
#This policy validates that IAM roles in the account are restrictive in what entities may assume them. This can help prevent malicious actors from assuming roles they should not be assuming.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
import json
from policyuniverse.policy import Policy
BAD_PRINCIPALS = {
"*",
}
def policy(resource):
if resource["AssumeRolePolicyDocument"] is None:
return True
iam_policy = Policy(json.loads(resource["AssumeRolePolicyDocument"]))
for statement in iam_policy.statements:
# Only apply to allow effects
if statement.effect != "Allow":
continue
# Don't apply where there are strong conditions
if statement.condition_entries:
continue
if BAD_PRINCIPALS.intersection(statement.principals):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_iam_role_restricts_usage.py
PolicyID: "AWS.IAM.Role.RestrictsUsage"
DisplayName: "AWS IAM Role Restricts Usage"
Enabled: true
ResourceTypes:
- AWS.IAM.Role
Tags:
- AWS
- Security, Identity & Compliance
- PCI
- Privilege Escalation:Valid Accounts
Reports:
PCI:
- 2.2.4
MITRE ATT&CK:
- TA0004:T1078
Severity: Medium
Description: >
This policy validates that IAM roles in the account are restrictive in what entities may assume them. This can help prevent malicious actors from assuming roles they should not be assuming.
Runbook: Assign an appropriate assume role policy to the IAM role.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_permissions-to-switch.html
Stages and Predicates
Flags AWS.IAM.Role resources when the condition below holds.
Condition
AssumeRolePolicyDocumentis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
AssumeRolePolicyDocument | is_null | excludes:AssumeRolePolicyDocument |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
AssumeRolePolicyDocument | is_not_null | field:"AssumeRolePolicyDocument" kind:is_not_null |
Response runbook
Assign an appropriate assume role policy to the IAM role.
AWS IAM Role Trust Relationship for GitHub Actions
#This policy ensures that IAM roles used with GitHub Actions are securely configured to prevent unauthorized access to AWS resources. It validates trust relationships by checking for proper audience (aud) restrictions, ensuring it is set to sts.amazonaws.com, and subject (sub) conditions, confirming they are scoped to specific repositories or environments. Misconfigurations, such as overly permissive wildcards or missing conditions, can allow unauthorized repositories to assume roles, leading to potential data breaches or compliance violations. By enforcing these checks, the policy mitigates risks of exploitation, enhances security posture, and protects critical AWS resources from external threats.
Detection logic
import json
from panther_base_helpers import deep_get
# Add/remove pairs as desired
ALLOWED_ORG_REPO_PAIRS = ["org/repo", "allowed-org-example/allowed-repo-example"]
def policy(resource):
# check if resource.AssumRolePolicyDocument is a string, and if so convert to json
if isinstance(resource.get("AssumeRolePolicyDocument"), str):
policy_document = json.loads(resource.get("AssumeRolePolicyDocument", {}))
else:
policy_document = resource.get("AssumeRolePolicyDocument", {})
assume_role_policy = policy_document.get("Statement", [])
for statement in assume_role_policy:
# only check for Allow sts:AssumeRoleWithWebIdentity
if (
statement.get("Effect") != "Allow"
or statement.get("Action") != "sts:AssumeRoleWithWebIdentity"
):
continue
principal = deep_get(statement, "Principal", "Federated")
audience = deep_get(
statement, "Condition", "StringEquals", "token.actions.githubusercontent.com:aud"
)
subject = deep_get(
statement,
"Condition",
"StringLike",
"token.actions.githubusercontent.com:sub",
default="",
) or deep_get(
statement,
"Condition",
"StringEquals",
"token.actions.githubusercontent.com:sub",
default="",
)
if subject.startswith("repo:"):
# repo subjects must have github as the principal and sts.amazonaws.com as the audience
if any(
[
"oidc-provider/token.actions.githubusercontent.com" not in principal,
audience != "sts.amazonaws.com",
(
"*" in subject
and not any(
subject.startswith(f"repo:{org_repo}:*")
for org_repo in ALLOWED_ORG_REPO_PAIRS
)
),
]
):
return False
else:
# non-repo subjects must not have github as the principal
if "oidc-provider/token.actions.githubusercontent.com" in principal:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_iam_role_github_actions_trust.py
PolicyID: "AWS.IAM.Role.GitHubActionsTrust"
DisplayName: "AWS IAM Role Trust Relationship for GitHub Actions"
Enabled: false
ResourceTypes:
- AWS.IAM.Role
Tags:
- AWS
- GitHub Actions
- Identity & Access Management
- Configuration Required
Severity: High
Description: >
This policy ensures that IAM roles used with GitHub Actions are securely configured to prevent unauthorized access to AWS resources.
It validates trust relationships by checking for proper audience (aud) restrictions, ensuring it is set to sts.amazonaws.com, and subject (sub) conditions,
confirming they are scoped to specific repositories or environments. Misconfigurations, such as overly permissive wildcards or missing conditions,
can allow unauthorized repositories to assume roles, leading to potential data breaches or compliance violations.
By enforcing these checks, the policy mitigates risks of exploitation, enhances security posture, and protects critical AWS resources from external threats.
Runbook: >
To fix roles flagged by this policy:
1. Update the trust relationship of the flagged IAM role in the AWS Management Console or CLI.
2. Add a Condition block with 'StringLike' or 'StringEquals' for 'token.actions.githubusercontent.com:sub'.
3. Ensure the audience is set to 'sts.amazonaws.com'.
4. Avoid overly permissive wildcards in the sub condition.
Reference: >
- https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html
- https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers
- https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
To fix roles flagged by this policy: 1. Update the trust relationship of the flagged IAM role in the AWS Management Console or CLI. 2. Add a Condition block with 'StringLike' or 'StringEquals' for 'token.actions.githubusercontent.com:sub'. 3. Ensure the audience is set to 'sts.amazonaws.com'. 4. Avoid overly permissive wildcards in the sub condition.
AWS IAM User MFA
#This policy validates that all AWS IAM users with access to the AWS Console have Multi-Factor Authentication (MFA) enabled.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
# If password logins are disabled, we don't need to worry about MFA
if not deep_get(resource, "CredentialReport", "PasswordEnabled"):
return True
# Explicit True check to avoid returning NoneType
return deep_get(resource, "CredentialReport", "MfaActive") is True
Rule specification
AnalysisType: policy
Filename: aws_iam_user_mfa.py
PolicyID: "AWS.IAM.User.MFA"
DisplayName: "AWS IAM User MFA "
Enabled: true
ResourceTypes:
- AWS.IAM.User
Tags:
- AWS
- Identity & Access Management
- Credential Access:Brute Force
Reports:
CIS:
- 1.2
PCI:
- 8.3.1
- 8.3.2
MITRE ATT&CK:
- TA0006:T1110
Severity: High
Description: >
This policy validates that all AWS IAM users with access to the AWS Console have Multi-Factor Authentication (MFA) enabled.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-iam-user-has-mfa-enabled
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable.html
Stages and Predicates
Flags AWS.IAM.User resources when all of the conditions below hold.
Condition
CredentialReport.PasswordEnabledis presentCredentialReport.MfaActiveis nottrue
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
CredentialReport.MfaActive | eq | true | excludes:CredentialReport.MfaActive field:"CredentialReport.MfaActive" value:"true" |
CredentialReport.PasswordEnabled | is_null | excludes:CredentialReport.PasswordEnabled |
Indicators
These rows show field, operator, and value matches.
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-iam-user-has-mfa-enabled
AWS IAM User Not In Conflicting Groups
#This policy validates that IAM users are not in IAM groups that are considered mutually exclusive. For example, in some workflows developers are responsible for dev environments and sysadmins are responsible for prod environments. In this situation no (or very few) users should be in both sysadmin and developer groups. This is in following with the principle of least privilege.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
# This policy ensures that users do not belong to groups that should be exclusive.
# A common example would be the Developer group and the Production admin group, as in tightly
# controlled environments developers should not be able to deploy to production directly and
# sysadmins should not have access to developmental source code.
#
# GROUP_CONFLICTS is formatted as a list of sets. Each inner set contains mutually exclusive groups.
GROUP_CONFLICTS = [
{"PROD_ADMIN", "DEV"},
]
def policy(resource):
group_names = {group["GroupName"] for group in resource["Groups"] or []}
# If the user is in more than one group in a mutually exclusive set, return False
for conflict_set in GROUP_CONFLICTS:
if len(group_names.intersection(conflict_set)) > 1:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_iam_user_not_in_conflicting_groups.py
PolicyID: "AWS.IAM.User.NotInConflictingGroups"
DisplayName: "AWS IAM User Not In Conflicting Groups"
Enabled: false
ResourceTypes:
- AWS.IAM.User
Tags:
- AWS
- Configuration Required
- Security, Identity & Compliance
- PCI
- Privilege Escalation:Valid Accounts
Reports:
PCI:
- 7.2.2
MITRE ATT&CK:
- TA0004:T1078
Severity: Medium
Description: >
This policy validates that IAM users are not in IAM groups that are considered mutually exclusive. For example, in some workflows developers are responsible for dev environments and sysadmins are responsible for prod environments. In this situation no (or very few) users should be in both sysadmin and developer groups. This is in following with the principle of least privilege.
Runbook: Remove the IAM user from one of the conflicting groups.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_groups.html
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
Remove the IAM user from one of the conflicting groups.
AWS IMDS Credential Usage Outside Expected Services
#Detects when an EC2 instance identity (credentials obtained via IMDS) is used to make API calls outside of expected internal AWS services like SSM. This indicates that IMDS credentials may have been exfiltrated from a compromised instance and are being used externally for lateral movement or privilege escalation.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation | |
| Stealth |
Detection logic
import ipaddress
import re
from panther_aws_helpers import aws_rule_context
from panther_ipinfo_helpers import get_ipinfo_asn
# IMDS credentials appear as assumed-role sessions where the session name
# is an EC2 instance ID (i-xxxxxxxxxxxxxxxxx)
INSTANCE_SESSION_PATTERN = re.compile(r":assumed-role/.+/i-[0-9a-f]+$")
# Legitimate internal services and actions that use instance identity
INTERNAL_SOURCES = {
"ssm.amazonaws.com",
"ec2messages.amazonaws.com", # SSM agent heartbeat/polling channel
"ssmmessages.amazonaws.com", # SSM Session Manager channel
}
INTERNAL_EVENTS = {"RegisterManagedInstance"}
INTERNAL_IPS = {"AWS Internal"}
def _is_valid_ip(ip_str):
try:
ipaddress.ip_address(ip_str)
return True
except ValueError:
return False
def _is_private_ip(ip_str):
try:
return ipaddress.ip_address(ip_str).is_private
except ValueError:
return False
def _is_amazon_domain(event):
"""Check if the source IP's ipinfo ASN enrichment resolves to an amazon.com domain."""
ipinfo_asn = get_ipinfo_asn(event)
if not ipinfo_asn:
return False
return ipinfo_asn.domain("sourceIPAddress") == "amazon.com"
def rule(event):
arn = event.deep_get("userIdentity", "arn", default="")
if not INSTANCE_SESSION_PATTERN.search(arn):
return False
# Exclude calls made by an AWS service on behalf of the instance (e.g. EKS, ECS, CodeDeploy)
if event.deep_get("userIdentity", "invokedBy", default="").endswith(".amazonaws.com"):
return False
# Exclude legitimate internal services
if event.get("eventSource") in INTERNAL_SOURCES:
return False
if event.get("eventName") in INTERNAL_EVENTS:
return False
# Only alert when credentials are used from a public IP.
# Filters out "AWS Internal", service hostnames (e.g. "eks.amazonaws.com"), and private VPC IPs.
source_ip = event.get("sourceIPAddress", "")
if source_ip in INTERNAL_IPS or source_ip.endswith(".amazonaws.com"):
return False
return _is_valid_ip(source_ip) and not _is_private_ip(source_ip)
def severity(event):
# Source IPs that resolve to an amazon.com ASN domain are lower risk
# (e.g. AWS-managed infrastructure making calls on the instance's behalf)
if _is_amazon_domain(event):
return "INFO"
return "DEFAULT"
def title(event):
arn = event.deep_get("userIdentity", "arn", default="<unknown>")
ip_addr = event.get("sourceIPAddress", "<unknown>")
action = event.get("eventName", "<unknown>")
return (
f"IMDS instance credential [{arn}] used from [{ip_addr}] "
f"to call [{event.get('eventSource', '')}:{action}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_imds_credential_exfiltration.py
RuleID: "AWS.CloudTrail.IMDSCredentialExfiltration"
DisplayName: "AWS IMDS Credential Usage Outside Expected Services"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- AWS STS
- Privilege Escalation:Valid Accounts
- Defense Evasion:Valid Accounts
Reports:
MITRE ATT&CK:
- TA0004:T1078.004
- TA0005:T1078.004
Status: Experimental
Severity: High
Description: >
Detects when an EC2 instance identity (credentials obtained via IMDS) is used to
make API calls outside of expected internal AWS services like SSM. This indicates
that IMDS credentials may have been exfiltrated from a compromised instance and
are being used externally for lateral movement or privilege escalation.
Runbook: |
1. Query CloudTrail for all API calls by userIdentity:arn in the 24 hours before and after this alert to identify the full scope of actions taken with the exfiltrated instance credentials
2. Check if sourceIPAddress is external to AWS or outside the expected VPC CIDR ranges for the EC2 instance associated with this role
3. Find all other alerts associated with this userIdentity:arn or sourceIPAddress in the past 7 days to assess whether this is part of a broader compromise campaign
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
userIdentity.invokedBydoes not end with.amazonaws.comeventSourceis not one ofssm.amazonaws.com,ec2messages.amazonaws.com,ssmmessages.amazonaws.comeventNameis not one ofRegisterManagedInstancesourceIPAddressis not one ofAWS InternalsourceIPAddressdoes not end with.amazonaws.com
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
sourceIPAddress | ends_with | .amazonaws.com | excludes:sourceIPAddress field:"sourceIPAddress" value:".amazonaws.com" |
sourceIPAddress | eq | AWS Internal | excludes:sourceIPAddress field:"sourceIPAddress" value:"AWS Internal" |
eventName | eq | RegisterManagedInstance | excludes:eventName field:"eventName" value:"RegisterManagedInstance" |
eventSource | in | ec2messages.amazonaws.com, ssm.amazonaws.com, ssmmessages.amazonaws.com | excludes:eventSource field:"eventSource" value:"ec2messages.amazonaws.com" field:"eventSource" value:"ssm.amazonaws.com" field:"eventSource" value:"ssmmessages.amazonaws.com" |
userIdentity.invokedBy | ends_with | .amazonaws.com | excludes:userIdentity.invokedBy field:"userIdentity.invokedBy" value:".amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Response runbook
1. Query CloudTrail for all API calls by userIdentity:arn in the 24 hours before and after this alert to identify the full scope of actions taken with the exfiltrated instance credentials
2. Check if sourceIPAddress is external to AWS or outside the expected VPC CIDR ranges for the EC2 instance associated with this role
3. Find all other alerts associated with this userIdentity:arn or sourceIPAddress in the past 7 days to assess whether this is part of a broader compromise campaign
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventName": "DescribeInstances",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2024-01-15T10:30:00Z",
"eventType": "AwsApiCall",
"recipientAccountId": "123456789012",
"sourceIPAddress": "45.33.32.156",
"userAgent": "aws-cli/2.15.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/MyAppRole/i-0abcdef1234567890",
"principalId": "AROAEXAMPLE:i-0abcdef1234567890",
"sessionContext": {
"sessionIssuer": {
"arn": "arn:aws:iam::123456789012:role/MyAppRole",
"type": "Role"
}
},
"type": "AssumedRole"
}
}
AWS KMS CMK Key Rotation
#This policy validates that customer master keys (CMKs) have automatic key rotation enabled.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Detection logic
def policy(resource):
# per AWS Docs automatic rotation is only supported on managed symmetric keys
# These are of Origin AWS_KMS
if resource.get("Origin") != "AWS_KMS":
return True
return ( # Ignore AWS managed keys
resource.get("KeyManager") != "CUSTOMER" # Check that the KeyRotation exists
# Explicit True check to avoid returning NoneType
or (resource.get("KeyRotationEnabled") is True and resource.get("KeyState") == "Enabled")
)
def dedup(resource):
return f"AWS KMS CMK Key Rotation - Account {resource.get('AccountId')}"
Rule specification
AnalysisType: policy
Filename: aws_cmk_key_rotation.py
PolicyID: "AWS.CMK.KeyRotation"
DisplayName: "AWS KMS CMK Key Rotation"
Enabled: true
ResourceTypes:
- AWS.KMS.Key
Tags:
- AWS
- Identity & Access Management
- Credential Access:Unsecured Credentials
Reports:
CIS:
- 2.8
PCI:
- 3.5.2
MITRE ATT&CK:
- TA0006:T1552
Severity: Low
Description: >
This policy validates that customer master keys (CMKs) have automatic key rotation enabled.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-customer-created-cmk-has-key-rotation-enabled
Reference: >
https://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html
Stages and Predicates
Flags AWS.KMS.Key resources when all of the conditions below hold.
Condition
OriginisAWS_KMSKeyManagerisCUSTOMERany of:
KeyRotationEnabledis nottrueKeyStateis notEnabled
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
KeyRotationEnabled | eq | true | excludes:KeyRotationEnabled field:"KeyRotationEnabled" value:"true" |
KeyState | eq | Enabled | excludes:KeyState field:"KeyState" value:"Enabled" |
KeyManager | ne | CUSTOMER | excludes:KeyManager field:"KeyManager" value:"CUSTOMER" |
Origin | ne | AWS_KMS | excludes:Origin field:"Origin" value:"AWS_KMS" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
KeyManager | eq |
| field:"KeyManager" kind:eq value:"CUSTOMER" |
KeyRotationEnabled | ne |
| field:"KeyRotationEnabled" kind:ne value:"true" |
KeyState | ne |
| field:"KeyState" kind:ne value:"Enabled" |
Origin | eq |
| field:"Origin" kind:eq value:"AWS_KMS" |
Response runbook
AWS KMS Key Restricts Usage
#This policy validates that KMS Keys restrict what entities can use them and how. This is to ensure that encryption keys are limited in who can use them in order to prevent unapproved decryption.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Detection logic
import json
from policyuniverse.policy import Policy
BAD_PRINCIPALS = {
"*",
}
BAD_ACTIONS = {
"*",
"kms:*",
}
def policy(resource):
if resource["Policy"] is None:
return True
iam_policy = Policy(json.loads(resource["Policy"]))
for statement in iam_policy.statements:
# Only apply to allow effects
if statement.effect != "Allow":
continue
# Don't apply where there are strong conditions
if statement.condition_entries:
continue
if BAD_PRINCIPALS.intersection(statement.principals) and BAD_ACTIONS.intersection(
statement.actions
):
return False
if statement.uses_not_principal():
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_kms_key_policy_restricts_usage.py
PolicyID: "AWS.KMS.RestrictsUsage"
DisplayName: "AWS KMS Key Restricts Usage"
Enabled: true
ResourceTypes:
- AWS.KMS.Key
Tags:
- AWS
- PCI
- Credential Access:Unsecured Credentials
Reports:
PCI:
- 3.5.2
MITRE ATT&CK:
- TA0006:T1552
Severity: High
Description: >
This policy validates that KMS Keys restrict what entities can use them and how. This is to ensure that encryption keys are limited in who can use them in order to prevent unapproved decryption.
Runbook: Add appropriate limitations to the KMS Key's policy.
Reference: https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html
Stages and Predicates
Flags AWS.KMS.Key resources when the condition below holds.
Condition
Policyis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Policy | is_null | excludes:Policy |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Policy | is_not_null | field:"Policy" kind:is_not_null |
Response runbook
Add appropriate limitations to the KMS Key's policy.
AWS Lambda Public Access
#This policy ensures that the function policy attached to the Lambda resource prohibits public access
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
import json
from panther_base_helpers import deep_get
def policy(resource):
json_policy = json.loads(deep_get(resource, "Policy", "Policy", default="{}"))
if any(
(statement.get("Principal") == "*" or deep_get(statement, "Principal", "AWS") == "*")
and statement.get("Effect") == "Allow"
and (
statement.get("Condition", {}) == {}
or deep_get(statement, "Condition", "StringEquals", "lambda:FunctionUrlAuthType")
== "NONE"
)
for statement in json_policy.get("Statement", [])
):
return False
return True
def severity(resource):
json_policy = json.loads(deep_get(resource, "Policy", "Policy", default="{}"))
if not any(
deep_get(statement, "Condition", "StringEquals", "lambda:FunctionUrlAuthType") == "NONE"
for statement in json_policy.get("Statement", [])
):
return "LOW"
return "DEFAULT"
Rule specification
AnalysisType: policy
Filename: aws_lambda_public_access.py
PolicyID: "AWS.Lambda.PublicAccess"
DisplayName: "AWS Lambda Public Access"
Enabled: true
ResourceTypes:
- AWS.Lambda.Function
Tags:
- AWS
- Data Protection
Reports:
MITRE ATT&CK:
- TA0001:T1190
Severity: High
Description: >
This policy ensures that the function policy attached to the Lambda resource prohibits public access
Reference: https://docs.aws.amazon.com/config/latest/developerguide/lambda-function-public-access-prohibited.html
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
AWS Macie Disabled/Updated
#Amazon Macie is a data security and data privacy service to discover and protect sensitive data. Security teams use Macie to detect open S3 Buckets that could have potentially sensitive data in it along with policy violations, such as missing Encryption. If an attacker disables Macie, it could potentially hide data exfiltration.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Detection logic
from panther_base_helpers import pattern_match
MACIE_EVENTS = {
"ArchiveFindings",
"CreateFindingsFilter",
"DeleteMember",
"DisassociateFromMasterAccount",
"DisassociateMember",
"DisableMacie",
"DisableOrganizationAdminAccount",
"UpdateFindingsFilter",
"UpdateMacieSession",
"UpdateMemberSession",
"UpdateClassificationJob",
}
def rule(event):
return event.get("eventName") in MACIE_EVENTS and pattern_match(
event.get("eventSource"), "macie*.amazonaws.com"
)
def title(event):
account = event.get("recipientAccountId")
user_arn = event.deep_get("userIdentity", "arn")
return f"AWS Macie in AWS Account [{account}] Disabled/Updated by [{user_arn}]"
Rule specification
AnalysisType: rule
Filename: aws_macie_evasion.py
RuleID: "AWS.Macie.Evasion"
DisplayName: "AWS Macie Disabled/Updated"
Enabled: true
LogTypes:
- AWS.CloudTrail
Reports:
MITRE ATT&CK:
- "TA0005:T1562" # Tactic ID:Technique ID (https://attack.mitre.org/tactics/enterprise/)
Severity: Medium
Description: >
Amazon Macie is a data security and data privacy service to discover and protect sensitive data.
Security teams use Macie to detect open S3 Buckets that could have potentially sensitive data in it along with
policy violations, such as missing Encryption. If an attacker disables Macie, it could potentially hide data exfiltration.
Reference: https://aws.amazon.com/macie/
Runbook: |
Analyze the events to ensure it's not normal maintenance.
If it's abnormal, run the Indicator Search on the UserIdentity:Arn for the past hour and analyze other services accessed/changed.
DedupPeriodMinutes: 60
Threshold: 5
SummaryAttributes:
- awsRegion
- eventName
- p_any_aws_arns
- p_any_ip_addresses
- userIdentity:type
- userIdentity:arn
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameis one ofArchiveFindings,CreateFindingsFilter,DeleteMember,DisassociateFromMasterAccount,DisassociateMembereventSourcematches the patternmacie*.amazonaws.com
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | wildcard |
| field:"aws::eventSource" kind:wildcard value:"macie*.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
recipientAccountId | |
arn | userIdentity.arn |
Response runbook
Analyze the events to ensure it's not normal maintenance.
If it's abnormal, run the Indicator Search on the UserIdentity:Arn for the past hour and analyze other services accessed/changed.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-2",
"eventCategory": "Management",
"eventID": "63033dfd-08c9-42f3-80ae-dca45e86ae84",
"eventName": "UpdateMacieSession",
"eventSource": "macie2.amazonaws.com",
"eventTime": "2022-09-27 19:59:08",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_any_aws_account_ids": [
"123456789012"
],
"p_any_aws_arns": [
"arn:aws:iam::123456789012:role/Admin",
"arn:aws:sts::123456789012:assumed-role/Admin/Jack"
],
"p_any_ip_addresses": [
"46.91.25.204"
],
"p_any_trace_ids": [
"ASIASWJRT64Z42HFV6QX"
],
"p_event_time": "2022-09-27 19:59:08",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2022-09-27 20:02:43.816",
"p_row_id": "665d45a409cad7d68ff7bbd4138123",
"p_source_id": "b00eb354-da7a-49dd-9cc6-32535e32096a",
"p_source_label": "CloudTrail Test",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1b9981dc-21d2-4f77-92b0-69e23c8a40de",
"requestParameters": {
"findingPublishingFrequency": "SIX_HOURS"
},
"sourceIPAddress": "46.91.25.204",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36",
"userIdentity": {
"accessKeyId": "ASIASWJRT64Z42HFV6QX",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/Admin/Jack",
"principalId": "AAAAA44444LE6DYFKKKKK:Jack",
"sessionContext": {
"attributes": {
"creationDate": "2022-09-27T17:56:01Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/Admin",
"principalId": "AAAAA44444LE6DYFKKKKK",
"type": "Role",
"userName": "Admin"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Modify Cloud Compute Infrastructure
#Detection when EC2 compute infrastructure is modified outside of expected automation methods.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS EC2 Export Task (Elastic)
- AWS EC2 Instance Profile Associated with Running Instance (Elastic)
- AWS EC2 Launch Unusual EC2 Instances (Panther)
- AWS EC2 Security Group Configuration Change (Elastic)
- AWS EC2 Startup Shell Script Change (Sigma)
- AWS EC2 Stop, Start, and User Data Modification Correlation (Elastic)
- AWS EC2 VM Export Failure (Sigma)
Detection logic
EC2_CRUD_ACTIONS = {
"AssociateIamInstanceProfile",
"AssociateInstanceEventWindow",
"BundleInstance",
"CancelSpotInstanceRequests",
"ConfirmProductInstance",
"CreateInstanceEventWindow",
"CreateInstanceExportTask",
"DeleteInstanceEventWindow",
"DeregisterInstanceEventNotificationAttributes",
"DisassociateIamInstanceProfile",
"DisassociateInstanceEventWindow",
"ImportInstance",
"ModifyInstanceAttribute",
"ModifyInstanceCapacityReservationAttributes",
"ModifyInstanceCreditSpecification",
"ModifyInstanceEventStartTime",
"ModifyInstanceEventWindow",
"ModifyInstanceMaintenanceOptions",
"ModifyInstanceMetadataOptions",
"ModifyInstancePlacement",
"MonitorInstances",
"RegisterInstanceEventNotificationAttributes",
"ReportInstanceStatus",
"RequestSpotInstances",
"ResetInstanceAttribute",
"RunInstances",
"RunScheduledInstances",
"StartInstances",
"StopInstances",
"TerminateInstances",
"UnmonitorInstances",
}
def rule(event):
# Disqualify any eventSource that is not ec2
if event.get("eventSource", "") != "ec2.amazonaws.com":
return False
if event.get("readOnly"):
return False
# Disqualify AWS Service-Service operations, which can appear in a variety of forms
if (
# FYI there is a weird quirk in the sourceIPAddress field of CloudTrail
# events with ec2.amazonaws.com as the source name where users of the
# web-console will have their sourceIPAddress recorded as "AWS Internal"
# though their userIdentity will be more normal.
# Example cloudtrail event in the "Terminate instance From WebUI with assumedRole" test
event.get("sourceIPAddress", "").endswith(".amazonaws.com")
or event.deep_get("userIdentity", "type", default="") == "AWSService"
or event.deep_get("userIdentity", "invokedBy", default="") == "AWS Internal"
or event.deep_get("userIdentity", "invokedBy", default="").endswith(".amazonaws.com")
):
return False
# Dry run operations get logged as SES Internal in the sourceIPAddress
# but not in the invokedBy field
if event.get("errorCode", "") == "Client.DryRunOperation":
return False
# Disqualify any eventNames that do not Include instance
# and events that have readOnly set to false
if event.get("eventName", "") in EC2_CRUD_ACTIONS:
return True
return False
def title(event):
items = event.deep_get(
"requestParameters", "instancesSet", "items", default=[{"instanceId": "none"}]
)
return (
f"AWS Event [{event.get('eventName')}] Instance ID "
f"[{items[0].get('instanceId')}] AWS Account ID [{event.get('recipientAccountId')}]"
)
def dedup(event):
items = event.deep_get(
"requestParameters",
"instancesSet",
"items",
default=[{"instanceId": "INSTANCE_ID_NOT_FOUND"}],
)
return items[0].get("instanceId", "INSTANCE_ID_NOT_FOUND")
def alert_context(event):
items = event.deep_get(
"requestParameters", "instancesSet", "items", default=[{"instanceId": "none"}]
)
return {
"awsRegion": event.get("awsRegion"),
"eventName": event.get("eventName"),
"recipientAccountId": event.get("recipientAccountId"),
"instanceId": items[0].get("instanceId"),
}
Rule specification
AnalysisType: rule
Description: Detection when EC2 compute infrastructure is modified outside of expected automation methods.
DisplayName: "AWS Modify Cloud Compute Infrastructure"
Enabled: false
Filename: aws_modify_cloud_compute_infrastructure.py
Reference: https://attack.mitre.org/techniques/T1578/
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0005:T1578
Tags:
# Note: This detection doesn't require configuration. It carries the Configuration Required
# tag due to checks for Enabled:false and Pack content
- Configuration Required
Runbook: |
This detection reports on eventSource ec2 Change events. This detection excludes Cross-Service
change events. As such, this detection will perform well in environments where changes are
expected to originate only from AWS service entities.
This detection will emit alerts frequently in environments where users are
making ec2 related changes.
DedupPeriodMinutes: 120
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.Modify.Cloud.Compute.Infrastructure"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisec2.amazonaws.comreadOnlyis emptysourceIPAddressdoes not end with.amazonaws.comuserIdentity.typeis notAWSServiceuserIdentity.invokedByis notAWS InternaluserIdentity.invokedBydoes not end with.amazonaws.comerrorCodeis notClient.DryRunOperationeventNameis one ofAssociateIamInstanceProfile,AssociateInstanceEventWindow,BundleInstance,CancelSpotInstanceRequests,ConfirmProductInstance
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
sourceIPAddress | ends_with | .amazonaws.com | excludes:sourceIPAddress field:"sourceIPAddress" value:".amazonaws.com" |
userIdentity.invokedBy | ends_with | .amazonaws.com | excludes:userIdentity.invokedBy field:"userIdentity.invokedBy" value:".amazonaws.com" |
userIdentity.invokedBy | eq | AWS Internal | excludes:userIdentity.invokedBy field:"userIdentity.invokedBy" value:"AWS Internal" |
userIdentity.type | eq | AWSService | excludes:userIdentity.type field:"userIdentity.type" value:"AWSService" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | ne |
| field:"aws::errorCode" kind:ne value:"Client.DryRunOperation" |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"ec2.amazonaws.com" |
readOnly | is_null | field:"readOnly" kind:is_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
awsRegion | |
eventName | |
recipientAccountId | |
items | requestParameters.instancesSet.items |
Response runbook
This detection reports on eventSource ec2 Change events. This detection excludes Cross-Service
change events. As such, this detection will perform well in environments where changes are
expected to originate only from AWS service entities.
This detection will emit alerts frequently in environments where users are
making ec2 related changes.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "59e8d6b8-de7b-43ca-961f-0c6f4531fcf0",
"eventName": "TerminateInstances",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2021-10-29 23:50:09",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_any_aws_account_ids": [
"111222333444"
],
"p_any_aws_arns": [
"arn:aws:iam::111222333444:role/FakeRole"
],
"p_any_aws_instance_ids": [
"i-0d9853f67e40ab80b"
],
"p_any_domain_names": [
"ec2.amazonaws.com"
],
"p_event_time": "2021-10-29 23:50:09",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2021-10-29 23:54:06.45",
"p_row_id": "e6f7bd65083bfeb7feced38f0da18a01",
"p_source_id": "5f9f0f60-9c56-4027-b93a-8bab3019f0f1",
"p_source_label": "SomeCloudTrail",
"readOnly": false,
"recipientAccountId": "111222333444",
"requestID": "a520eeaf-c258-4260-954e-b4a976e6c72b",
"requestParameters": {
"instancesSet": {
"items": [
{
"instanceId": "i-0d9853f67e40ab80b"
}
]
}
},
"responseElements": {
"instancesSet": {
"items": [
{
"currentState": {
"code": 32,
"name": "shutting-down"
},
"instanceId": "i-0d9853f67e40ab80b",
"previousState": {
"code": 16,
"name": "running"
}
}
],
"requestId": "a520eeaf-c258-4260-954e-b4a976e6c72b"
}
},
"userIdentity": {
"accountId": "111222333444",
"arn": "arn:aws:sts::111222333444:assumed-role/SomeRole/AThing",
"sessionContext": {
"attributes": {
"creationDate": "2021-10-29T23:50:08Z",
"mfaAuthenticated": "false"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Network ACL Overly Permissive Entry Created
#A Network ACL entry that allows access from anywhere was added.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
# Only check successful actions creating a new Network ACL entry
if not aws_cloudtrail_success(event) or event.get("eventName") != "CreateNetworkAclEntry":
return False
# Check if this new NACL entry is allowing traffic from anywhere
return (
event.deep_get("requestParameters", "cidrBlock") == "0.0.0.0/0"
and event.deep_get("requestParameters", "ruleAction") == "allow"
and event.deep_get("requestParameters", "egress") is False
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_network_acl_permissive_entry.py
RuleID: "AWS.CloudTrail.NetworkACLPermissiveEntry"
DisplayName: "AWS Network ACL Overly Permissive Entry Created"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Persistence:Account Manipulation
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0003:T1098
Description: >
A Network ACL entry that allows access from anywhere was added.
Runbook: >
Remove the overly permissive Network ACL entry and add a new entry with more restrictive permissions.
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html#nacl-rules
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisCreateNetworkAclEntryrequestParameters.cidrBlockis0.0.0.0/0requestParameters.ruleActionisallowrequestParameters.egressisfalse
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | ne | CreateNetworkAclEntry | excludes:eventName field:"eventName" value:"CreateNetworkAclEntry" |
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Remove the overly permissive Network ACL entry and add a new entry with more restrictive permissions.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1111",
"eventName": "CreateNetworkAclEntry",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1111",
"requestParameters": {
"aclProtocol": "6",
"cidrBlock": "0.0.0.0/0",
"egress": false,
"icmpTypeCode": {},
"networkAclId": "acl-1111",
"portRange": {
"from": 700,
"to": 702
},
"ruleAction": "allow",
"ruleNumber": 12
},
"responseElements": {
"_return": true,
"requestId": "1111"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla/2.0 (compatible; NEWT ActiveX; Win32)",
"userIdentity": {
"accessKeyId": "1111",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/example-role/example-user",
"principalId": "1111",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/example-role",
"principalId": "1111",
"type": "Role",
"userName": "example-role"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Network ACL Restricts Inbound Traffic
#This policy validates that Network ACLs restrict inbound traffic in some way.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
# This is a generic policy for checking inbound rules on a Network ACL.
# It is recommended to add additional logic here based on your own use cases.
def policy(resource):
for entry in resource["Entries"]:
if entry["RuleAction"] == "allow" and not entry["Egress"]:
# Check if entry is set to "All Ports"
if entry["PortRange"] is None:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_network_acl_restricts_inbound_traffic.py
PolicyID: "AWS.NetworkACL.RestrictsInboundTraffic"
DisplayName: "AWS Network ACL Restricts Inbound Traffic"
Enabled: false
ResourceTypes:
- AWS.EC2.NetworkACL
Tags:
- AWS
- PCI
- Initial Access:Exploit Public-Facing Application
Reports:
PCI:
- 1.3.5
- 1.2.1
- 1.1.4
MITRE ATT&CK:
- TA0001:T1190
Severity: Medium
Description: >
This policy validates that Network ACLs restrict inbound traffic in some way.
Runbook: >
Add appropriate entries to the Network ACLs' IP permissions list.
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/vpc-recommended-nacl-rules.html
Stages and Predicates
Flags AWS.EC2.NetworkACL resources when the condition below holds.
Condition
any element of
Entriesmatches all of:Entries.RuleActionisallowEntries.Egressis emptyEntries.PortRangeis empty
Response runbook
Add appropriate entries to the Network ACLs' IP permissions list.
AWS Network ACL Restricts Insecure Protocols
#This policy validates that Network ACLs block the usage of ports typically associated with insecure or unencrypted protocols.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Command & Control |
Detection logic
# This is a list of default ports for insecure protocols. As AWS Network ACLs and Security Groups
# are not application layer aware, this is the closest approximation that can be made to blocking
# insecure protocols. Application layer firewalls can provide stronger protections.
INSECURE_PORTS = {
21, # FTP command channel
25, # Unencrypted pop3 outgoing
23, # Telnet
80, # HTTP
110, # Unencrypted pop3 incoming
587, # Unencrypted pop3 outgoing
}
def policy(resource):
for entry in resource["Entries"]:
# Look for ingress rules from any IP.
# This could be modified in the future to inspect the size
# of the source network with the ipaddress.ip_network.num_addresses call.
if entry["Egress"]:
continue
# This indicates that all protocols are allowed, and the port range is ignored
if entry["Protocol"] == "-1" or not entry["PortRange"]:
return False
if any(
entry["PortRange"]["From"] <= port <= entry["PortRange"]["To"]
for port in INSECURE_PORTS
):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_network_acl_restricts_insecure_protocols.py
PolicyID: "AWS.NetworkACL.RestrictsInsecureProtocols"
DisplayName: "AWS Network ACL Restricts Insecure Protocols"
Enabled: false
ResourceTypes:
- AWS.EC2.NetworkACL
Tags:
- AWS
- PCI
- Command and Control:Non-Application Layer Protocol
Reports:
PCI:
- 8.2.1
MITRE ATT&CK:
- TA0011:T1095
Severity: Low
Description: >
This policy validates that Network ACLs block the usage of ports typically associated with insecure or unencrypted protocols.
Runbook: Add Network ACL entries to block ports typically associated with insecure protocols.
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/vpc-recommended-nacl-rules.html
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
Add Network ACL entries to block ports typically associated with insecure protocols.
AWS Network ACL Restricts Outbound Traffic
#This policy validates that Network ACLs have some restrictions on outbound traffic.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Detection logic
# This is generic policy that checks outbound traffic rules on a Network ACL.
# It is recommended you add additional logic for your own use cases.
def policy(resource):
for entry in resource["Entries"]:
if entry["RuleAction"] == "allow" and entry["Egress"]:
# Check if entry is set to "All Ports"
if entry["PortRange"] is None:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_network_acl_restricts_outbound_traffic.py
PolicyID: "AWS.NetworkACL.RestrictsOutboundTraffic"
DisplayName: "AWS Network ACL Restricts Outbound Traffic"
Enabled: false
ResourceTypes:
- AWS.EC2.NetworkACL
Tags:
- AWS
- PCI
- Exfiltration:Exfiltration Over Web Service
Reports:
PCI:
- 1.1.4
- 1.3.2
MITRE ATT&CK:
- TA0010:T1567
Severity: Low
Description: >
This policy validates that Network ACLs have some restrictions on outbound traffic.
Runbook: >
Add appropriate restrictions on outbound traffic.
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/vpc-recommended-nacl-rules.html
Stages and Predicates
Flags AWS.EC2.NetworkACL resources when the condition below holds.
Condition
any element of
Entriesmatches all of:Entries.RuleActionisallowEntries.Egressis presentEntries.PortRangeis empty
Response runbook
Add appropriate restrictions on outbound traffic.
AWS Network ACL Restricts SSH
#SSH access should only be granted from protected network CIDR ranges.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Lateral Movement |
Detection logic
import ipaddress
GLOBAL_IPV6 = ipaddress.IPv6Network("::/0")
# Choose an arbitrary sentinel value that isn't equivalent to the GLOBAL_IPV6 value.
IPV6_SENTINEL = ipaddress.IPv6Network("::1/128")
def policy(resource):
# Enumerate the entries in the network ACL, in evaluation order.
ingress_entries = sorted(
(entry for entry in resource["Entries"] if not entry["Egress"]),
key=lambda x: x["RuleNumber"],
)
for entry in ingress_entries:
# Look for SSH ingress rules from wildcard IPs.
if (
entry.get("CidrBlock") == "0.0.0.0/0"
# Handle non-standard representations like `"0::/0"`.
or ipaddress.IPv6Network(entry.get("Ipv6CidrBlock") or IPV6_SENTINEL) == GLOBAL_IPV6
) and (
not entry.get("PortRange")
or entry["PortRange"]["From"] <= 22 <= entry["PortRange"]["To"]
):
# If this is a deny rule, then the ACL has an explicit deny rule with a lower (more
# important) precedence than any rule that would allow SSH from arbitrary IPs. If it's
# an allow rule, then the opposite is true. Either way, this rule determines the
# entire outcome of the policy evaluation.
#
# Another way to read this: pass the policy check if the SSH rule here is a deny.
return entry["RuleAction"] == "deny"
# Found no SSH ingress rules from wildcard IPs.
return True
Rule specification
AnalysisType: policy
Filename: aws_network_acl_restricted_ssh.py
PolicyID: "AWS.NetworkACL.RestrictedSSH"
DisplayName: "AWS Network ACL Restricts SSH"
Enabled: true
ResourceTypes:
- AWS.EC2.NetworkACL
Tags:
- AWS
- Panther
- Lateral Movement:Remote Services
Reports:
MITRE ATT&CK:
- TA0008:T1021
Severity: High
Description: >
SSH access should only be granted from protected network CIDR ranges.
Runbook: >
Remove the NACL rule granting unprotected SSH access.
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/vpc-recommended-nacl-rules.html
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
Remove the NACL rule granting unprotected SSH access.
AWS Password Policy Complexity Guidelines
#This policy validates that the account password policy enforces the recommended password complexity requirements.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Detection logic
# if you want to enforce any of the password policies below, leave them commented.
IGNORED = {
# "RequireUppercaseCharacters",
# "RequireLowercaseCharacters",
# "RequireSymbols",
# "RequireNumbers",
}
def policy(resource):
if (
not resource.get("RequireUppercaseCharacters")
and "RequireUppercaseCharacters" not in IGNORED
):
return False
if (
not resource.get("RequireLowercaseCharacters")
and "RequireLowercaseCharacters" not in IGNORED
):
return False
if not resource.get("RequireSymbols") and "RequireSymbols" not in IGNORED:
return False
if not resource.get("RequireNumbers") and "RequireNumbers" not in IGNORED:
return False
if (
not (
resource.get("MinimumPasswordLength") and resource.get("MinimumPasswordLength", 0) >= 14
)
and "MinimumPasswordLength" not in IGNORED
):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_password_policy_complexity_guidelines.py
PolicyID: "AWS.PasswordPolicy.ComplexityGuidelines"
DisplayName: "AWS Password Policy Complexity Guidelines"
Enabled: true
ResourceTypes:
- AWS.PasswordPolicy
Tags:
- AWS
- Identity & Access Management
- Credential Access:Brute Force
- Configuration Required
Reports:
CIS:
- 1.5
- 1.6
- 1.7
- 1.8
- 1.9
PCI:
- 8.2.3
MITRE ATT&CK:
- TA0006:T1110
Severity: High
Description: >
This policy validates that the account password policy enforces the recommended password complexity requirements.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-account-password-policy-enforces-complexity-guidelines
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html
Stages and Predicates
Flags AWS.PasswordPolicy resources when any of the conditions below holds.
Condition
any of:
RequireUppercaseCharactersis emptyRequireLowercaseCharactersis emptyRequireSymbolsis emptyRequireNumbersis emptyMinimumPasswordLengthis emptyMinimumPasswordLengthis less than14
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
MinimumPasswordLength | ge | 14 | excludes:MinimumPasswordLength field:"MinimumPasswordLength" value:"14" |
MinimumPasswordLength | is_not_null | excludes:MinimumPasswordLength | |
RequireLowercaseCharacters | is_null | excludes:RequireLowercaseCharacters | |
RequireNumbers | is_null | excludes:RequireNumbers | |
RequireSymbols | is_null | excludes:RequireSymbols | |
RequireUppercaseCharacters | is_null | excludes:RequireUppercaseCharacters |
Indicators
These rows show field, operator, and value matches.
Response runbook
AWS Password Policy Password Age Limit
#This policy validates that the account password policy enforces a maximum password age of 90 days or less.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Detection logic
def policy(resource):
if resource["MaxPasswordAge"] is None:
return False
return resource["MaxPasswordAge"] <= 90
Rule specification
AnalysisType: policy
Filename: aws_password_policy_password_age_limit.py
PolicyID: "AWS.PasswordPolicy.PasswordAgeLimit"
DisplayName: "AWS Password Policy Password Age Limit"
Enabled: true
ResourceTypes:
- AWS.PasswordPolicy
Tags:
- AWS
- Identity & Access Management
- Credential Access:Brute Force
Reports:
CIS:
- 1.11
PCI:
- 8.2.4
MITRE ATT&CK:
- TA0006:T1110
Severity: Medium
Description: >
This policy validates that the account password policy enforces a maximum password age of 90
days or less.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-account-password-policy-enforces-password-age-limit-of-90-days-or-less
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html
Stages and Predicates
Flags AWS.PasswordPolicy resources when any of the conditions below holds.
Condition
any of:
MaxPasswordAgeis emptyMaxPasswordAgeis greater than90
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
MaxPasswordAge | is_not_null | excludes:MaxPasswordAge | |
MaxPasswordAge | le | 90 | excludes:MaxPasswordAge field:"MaxPasswordAge" value:"90" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
MaxPasswordAge | gt |
| field:"MaxPasswordAge" kind:gt value:"90" |
MaxPasswordAge | is_null | field:"MaxPasswordAge" kind:is_null |
Response runbook
AWS Password Policy Password Reuse
#This policy validates that the account password policy prevents users from re-using previous passwords, and prevents password reuse for 24 or more prior passwords.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Detection logic
def policy(resource):
if resource["PasswordReusePrevention"] is None:
return False
return resource["PasswordReusePrevention"] >= 24
Rule specification
AnalysisType: policy
Filename: aws_password_policy_password_reuse.py
PolicyID: "AWS.PasswordPolicy.PasswordReuse"
DisplayName: "AWS Password Policy Password Reuse"
Enabled: true
ResourceTypes:
- AWS.PasswordPolicy
Tags:
- AWS
- Identity & Access Management
- Credential Access:Brute Force
Reports:
CIS:
- 1.10
PCI:
- 8.2.5
MITRE ATT&CK:
- TA0006:T1110
Severity: Medium
Description: >
This policy validates that the account password policy prevents users from re-using previous
passwords, and prevents password reuse for 24 or more prior passwords.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-account-password-policy-prevents-password-reuse
Reference: >
https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html
Stages and Predicates
Flags AWS.PasswordPolicy resources when any of the conditions below holds.
Condition
any of:
PasswordReusePreventionis emptyPasswordReusePreventionis less than24
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
PasswordReusePrevention | ge | 24 | excludes:PasswordReusePrevention field:"PasswordReusePrevention" value:"24" |
PasswordReusePrevention | is_not_null | excludes:PasswordReusePrevention |
Indicators
These rows show field, operator, and value matches.
Response runbook
AWS Potential Backdoor Lambda Function Through Resource-Based Policy
#Identifies when a permission is added to a Lambda function, which could indicate a potential security risk.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
ADD_PERMISSION_EVENTS = {
"AddPermission20150331",
"AddPermission20150331v2",
}
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "lambda.amazonaws.com"
and event.get("eventName") in ADD_PERMISSION_EVENTS
)
def title(event):
lambda_name = event.deep_get(
"requestParameters", "functionName", default="LAMBDA_NAME_NOT_FOUND"
)
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"added permission to Lambda function [{lambda_name}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_backdoor_lambda_function.py
RuleID: "AWS.Potential.Backdoor.Lambda"
DisplayName: "AWS Potential Backdoor Lambda Function Through Resource-Based Policy"
Enabled: true
LogTypes:
- AWS.CloudTrail
Reports:
MITRE ATT&CK:
- TA0007:T1078
Stratus Red Team:
- aws.persistence.lambda-backdoor-function
Severity: Info
Status: Experimental
Description: >
Identifies when a permission is added to a Lambda function, which could indicate a potential security risk.
Runbook: Make sure that the permission is legitimate and necessary. If not, remove the permission
Reference: https://docs.aws.amazon.com/lambda/latest/dg/API_AddPermission.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceislambda.amazonaws.comeventNameis one ofAddPermission20150331,AddPermission20150331v2
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"lambda.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
functionName | requestParameters.functionName |
Response runbook
Make sure that the permission is legitimate and necessary. If not, remove the permission
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "AddPermission20150331",
"eventSource": "lambda.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"p_log_type": "AWS.CloudTrail",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"functionName": "my-lambda-function"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "Tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Potentially Stolen Service Role
#A role was assumed by an AWS service, followed by a user within 24 hours. This could indicate a stolen or compromised AWS service role.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Rules detecting the same action
These rules filter on the same operation.
- ASL AWS IAM Assume Role Policy Brute Force (Splunk)
- AWS Potentially Stolen Service Role (Panther)
- SIGNAL - Role Assumed by AWS Service (Panther)
- SIGNAL - Role Assumed by User (Panther)
Detection logic
def rule(_):
return True
Rule specification
AnalysisType: scheduled_rule
RuleID: "AWS.Potentially.Stolen.Service.Role.Scheduled"
DisplayName: "AWS Potentially Stolen Service Role"
Enabled: true
Tags:
- AWS
Severity: High
Reports:
MITRE ATT&CK:
- TA0006:T1528 # Steal Application Access Token
Description: A role was assumed by an AWS service, followed by a user within 24 hours. This could indicate a stolen or compromised AWS service role.
Filename: aws_potentially_compromised_service_role.py
ScheduledQueries:
- "AWS Potentially Stolen Service Role"
Stages and Predicates
Rule logic
This rule alerts on rows returned by its scheduled query AWS Potentially Stolen Service Role; its Python module (Detection logic above) shapes the alert rather than filtering.
AWS Potentially Stolen Service Role
#A role was assumed by an AWS service, followed by a user within 24 hours. This could indicate a stolen or compromised AWS service role.
Rules detecting the same action
These rules filter on the same operation.
- ASL AWS IAM Assume Role Policy Brute Force (Splunk)
- AWS Potentially Stolen Service Role (Panther)
- SIGNAL - Role Assumed by AWS Service (Panther)
- SIGNAL - Role Assumed by User (Panther)
Rule specification
AnalysisType: scheduled_query
Description: A role was assumed by an AWS service, followed by a user within 24 hours. This could indicate a stolen or compromised AWS service role.
Enabled: false
SnowflakeQuery: |
SELECT
requestParameters:roleArn AS role,
ARRAY_AGG(distinct userIdentity:principalId) AS users,
ARRAY_AGG(distinct userIdentity:type) AS types
FROM
panther_logs.public.aws_cloudtrail
WHERE
P_OCCURS_SINCE('1 day')
AND eventName = 'AssumeRole'
AND errorCode IS NULL
GROUP BY role
HAVING
ARRAY_SIZE(types) > 1
AND ARRAY_CONTAINS('AWSService'::VARIANT, types)
LIMIT 100
DatabricksQuery: |
SELECT
requestParameters:roleArn AS role,
COLLECT_SET(userIdentity:principalId) AS users,
COLLECT_SET(userIdentity:type) AS types
FROM
panther_logs.aws_cloudtrail
WHERE
P_OCCURS_SINCE('1 day')
AND eventName = 'AssumeRole'
AND errorCode IS NULL
GROUP BY role
HAVING
SIZE(types) > 1
AND ARRAY_CONTAINS(types, 'AWSService')
LIMIT 100
QueryName: "AWS Potentially Stolen Service Role"
Schedule:
RateMinutes: 1440
TimeoutMinutes: 5
Stages and Predicates
Stage 1: source
Stage 2: filter
eventNameisAssumeRoleerrorCodeis empty
Stage 3: having
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | eq |
| field:"aws::eventName" kind:eq value:"AssumeRole" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
role | requestParameters:roleArn |
users | ARRAY_AGG ( DISTINCT userIdentity:principalId ) |
types | ARRAY_AGG ( DISTINCT userIdentity:type ) |
AWS Privilege Escalation Via User Compromise
#MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Rule specification
AnalysisType: correlation_rule
RuleID: "AWS.Privilege.Escalation.Via.User.Compromise.Group"
DisplayName: "AWS Privilege Escalation Via User Compromise"
Enabled: false
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0004:T1098.001 # Additional Cloud Credentials
Detection:
- Group:
- ID: User Backdoored
RuleID: AWS.IAM.Backdoor.User.Keys
- ID: User Accessed
RuleID: AWS.CloudTrail.UserAccessKeyAuth
MatchCriteria:
field_name:
- GroupID: User Backdoored
Match: p_alert_context.ip_accessKeyId
- GroupID: User Accessed
Match: p_alert_context.ip_accessKeyId
Schedule:
RateMinutes: 1440
TimeoutMinutes: 10
LookbackWindowMinutes: 1800
Stages and Predicates
Fires when the steps below all occur within 30h, correlated by p_alert_context.ip_accessKeyId. Each step needs one match unless a higher minimum is shown.
Stage 1: step User Backdoored
References detection AWS User API Key Created.
Stage 2: step User Accessed
References detection AWS.CloudTrail.UserAccessKeyAuth.
AWS Public RDS Restore
#Detects the recovery of a new public database instance from a snapshot. It may be part of data exfiltration.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event RestoreDBInstanceFromDBSnapshot: Creates a new DB instance from a DB snapshot. |
Rules detecting the same action
These rules filter on the same operation.
- AWS RDS DB Instance Restored (Elastic)
- Restore Public AWS RDS Instance (Sigma)
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if (
event.get("eventSource", "") == "rds.amazonaws.com"
and event.get("eventName", "") == "RestoreDBInstanceFromDBSnapshot"
):
if event.deep_get("responseElements", "publiclyAccessible"):
return True
return False
def title(event):
return f"Publicly Accessible RDS restore created in [{event.get('recipientAccountId','')}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: Detects the recovery of a new public database instance from a snapshot. It may be part of data exfiltration.
DisplayName: "AWS Public RDS Restore"
Enabled: true
Filename: aws_rds_publicrestore.py
Reports:
MITRE ATT&CK:
- TA0010:T1020
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_RestoreFromSnapshot.html
Severity: High
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.RDS.PublicRestore"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameisRestoreDBInstanceFromDBSnapshotresponseElements.publiclyAccessibleis present
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"RestoreDBInstanceFromDBSnapshot" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
responseElements.publiclyAccessible | is_not_null | field:"responseElements.publiclyAccessible" kind:is_not_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "797163d3-5726-441d-80a7-6eeb7464acd4",
"eventName": "RestoreDBInstanceFromDBSnapshot",
"eventSource": "rds.amazonaws.com",
"eventTime": "2018-07-30T22:14:06Z",
"eventType": "AwsApiCall",
"eventVersion": "1.04",
"recipientAccountId": "123456789012",
"requestID": "daf2e3f5-96a3-4df7-a026-863f96db793e",
"requestParameters": {
"allocatedStorage": 20,
"dBInstanceClass": "db.m1.small",
"dBInstanceIdentifier": "test-instance",
"enableCloudwatchLogsExports": [
"audit",
"error",
"general",
"slowquery"
],
"engine": "mysql",
"masterUserPassword": "****",
"masterUsername": "myawsuser"
},
"responseElements": {
"allocatedStorage": 20,
"autoMinorVersionUpgrade": true,
"backupRetentionPeriod": 1,
"cACertificateIdentifier": "rds-ca-2015",
"copyTagsToSnapshot": false,
"dBInstanceArn": "arn:aws:rds:us-east-1:123456789012:db:test-instance",
"dBInstanceClass": "db.m1.small",
"dBInstanceIdentifier": "test-instance",
"dBInstanceStatus": "creating",
"dBParameterGroups": [
{
"dBParameterGroupName": "default.mysql8.0",
"parameterApplyStatus": "in-sync"
}
],
"dBSecurityGroups": [],
"dBSubnetGroup": {
"dBSubnetGroupDescription": "default",
"dBSubnetGroupName": "default",
"subnetGroupStatus": "Complete",
"subnets": [
{
"subnetAvailabilityZone": {
"name": "us-east-1b"
},
"subnetIdentifier": "subnet-cbfff283",
"subnetStatus": "Active"
},
{
"subnetAvailabilityZone": {
"name": "us-east-1e"
},
"subnetIdentifier": "subnet-d7c825e8",
"subnetStatus": "Active"
},
{
"subnetAvailabilityZone": {
"name": "us-east-1f"
},
"subnetIdentifier": "subnet-6746046b",
"subnetStatus": "Active"
},
{
"subnetAvailabilityZone": {
"name": "us-east-1c"
},
"subnetIdentifier": "subnet-bac383e0",
"subnetStatus": "Active"
},
{
"subnetAvailabilityZone": {
"name": "us-east-1d"
},
"subnetIdentifier": "subnet-42599426",
"subnetStatus": "Active"
},
{
"subnetAvailabilityZone": {
"name": "us-east-1a"
},
"subnetIdentifier": "subnet-da327bf6",
"subnetStatus": "Active"
}
],
"vpcId": "vpc-136a4c6a"
},
"dbInstancePort": 0,
"dbiResourceId": "db-ETDZIIXHEWY5N7GXVC4SH7H5IA",
"domainMemberships": [],
"engine": "mysql",
"engineVersion": "8.0.28",
"iAMDatabaseAuthenticationEnabled": false,
"licenseModel": "general-public-license",
"masterUsername": "myawsuser",
"monitoringInterval": 0,
"multiAZ": false,
"optionGroupMemberships": [
{
"optionGroupName": "default:mysql-8-0",
"status": "in-sync"
}
],
"pendingModifiedValues": {
"masterUserPassword": "****",
"pendingCloudwatchLogsExports": {
"logTypesToEnable": [
"audit",
"error",
"general",
"slowquery"
]
}
},
"performanceInsightsEnabled": false,
"preferredBackupWindow": "10:27-10:57",
"preferredMaintenanceWindow": "sat:05:47-sat:06:17",
"publiclyAccessible": true,
"readReplicaDBInstanceIdentifiers": [],
"storageEncrypted": false,
"storageType": "standard",
"vpcSecurityGroups": [
{
"status": "active",
"vpcSecurityGroupId": "sg-f839b688"
}
]
},
"sourceIPAddress": "192.0.2.0",
"userAgent": "aws-cli/1.15.42 Python/3.6.1 Darwin/17.7.0 botocore/1.10.42",
"userIdentity": {
"accessKeyId": "AKIAI44QH8DHBEXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/johndoe",
"principalId": "AKIAIOSFODNN7EXAMPLE",
"type": "IAMUser",
"userName": "johndoe"
}
}
AWS RDS Activity Stream Stopped
#Detects when RDS Database Activity Streams are stopped. Activity Streams provide real-time monitoring of database activity. Disabling them is a clear evasion technique used by attackers to avoid detection before performing malicious operations.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
if event.get("eventName") != "StopActivityStream":
return False
return event.deep_get("errorCode") is None
def title(event):
db_identifier = event.deep_get("requestParameters", "resourceArn", default="<UNKNOWN>").split(
":"
)[-1]
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
return f"RDS Activity Stream Stopped: [{db_identifier}] by [{user}]"
def alert_context(event):
context = aws_rule_context(event)
context["resource_arn"] = event.deep_get("requestParameters", "resourceArn", default="N/A")
context["apply_immediately"] = event.deep_get(
"requestParameters", "applyImmediately", default="N/A"
)
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_activity_stream_stopped.py
RuleID: "AWS.RDS.ActivityStreamStopped"
DisplayName: "AWS RDS Activity Stream Stopped"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Defense Evasion
- Impair Defenses
- Disable Cloud Logs
- RDS
Severity: High
Description: >
Detects when RDS Database Activity Streams are stopped. Activity Streams provide real-time
monitoring of database activity. Disabling them is a clear evasion technique used by
attackers to avoid detection before performing malicious operations.
Runbook: |
1. Find all RDS API calls by the user ARN in the 24 hours before the alert
2. Check if activity streams have been stopped by this user in the past 90 days to determine if this is routine maintenance
3. Look for sensitive database operations from this user in the 2 hours after the stream was stopped
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/DBActivityStreams.html
Reports:
MITRE ATT&CK:
- TA0005:T1562.008 # Disable or Modify Cloud Logs
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:resourceArn
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameisStopActivityStreamerrorCodeis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | eq |
| field:"aws::eventName" kind:eq value:"StopActivityStream" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
userName | userIdentity.userName |
Response runbook
1. Find all RDS API calls by the user ARN in the 24 hours before the alert
2. Check if activity streams have been stopped by this user in the past 90 days to determine if this is routine maintenance
3. Look for sensitive database operations from this user in the 2 hours after the stream was stopped
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "StopActivityStream",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-15T20:30:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"applyImmediately": true,
"resourceArn": "arn:aws:rds:us-east-1:123456789012:cluster:production-aurora"
},
"responseElements": {
"kmsKeyId": "arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab",
"status": "stopping"
},
"sourceIPAddress": "203.0.113.45",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/DeveloperRole/user",
"principalId": "AIDAI23HXS3EXAMPLE:user",
"sessionContext": {
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/DeveloperRole",
"principalId": "AIDAI23HXS3EXAMPLE",
"type": "Role",
"userName": "DeveloperRole"
}
},
"type": "AssumedRole"
}
}
AWS RDS Automated Backup Deleted
#Detects deletion of RDS automated backups. This is a classic ransomware tactic where attackers delete automated backups before encrypting or destroying databases to prevent recovery. Any automated backup deletion should be investigated immediately.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
deletion_events = ["DeleteDBInstanceAutomatedBackup", "DeleteDBClusterAutomatedBackup"]
if event.get("eventName") not in deletion_events:
return False
return event.deep_get("errorCode") is None
def title(event):
event_name = event.get("eventName", "Unknown")
backup_arn = event.deep_get("requestParameters", "dbiResourceId") or event.deep_get(
"requestParameters", "dbClusterResourceId", default="<UNKNOWN>"
)
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
resource_type = (
"Automated Backup" if event_name == "DeleteDBInstanceAutomatedBackup" else "Cluster Backup"
)
return f"RDS {resource_type} Deleted: [{backup_arn}] by [{user}]"
def dedup(event):
backup_id = event.deep_get("requestParameters", "dbiResourceId") or event.deep_get(
"requestParameters", "dbClusterResourceId", default="unknown"
)
account_id = event.deep_get("recipientAccountId", default="unknown")
region = event.get("awsRegion", "unknown")
return f"{account_id}:{region}:{backup_id}"
def alert_context(event):
context = aws_rule_context(event)
context["dbi_resource_id"] = event.deep_get("requestParameters", "dbiResourceId", default="N/A")
context["db_cluster_resource_id"] = event.deep_get(
"requestParameters", "dbClusterResourceId", default="N/A"
)
context["backup_arn"] = event.deep_get(
"responseElements", "dBInstanceAutomatedBackupArn"
) or event.deep_get("responseElements", "dBClusterAutomatedBackupArn", default="N/A")
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_automated_backup_deleted.py
RuleID: "AWS.RDS.AutomatedBackupDeleted"
DisplayName: "AWS RDS Automated Backup Deleted"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Impact
- Defense Evasion
- Data Destruction
- Inhibit System Recovery
- Ransomware
- RDS
Severity: Critical
Description: >
Detects deletion of RDS automated backups. This is a classic ransomware tactic where
attackers delete automated backups before encrypting or destroying databases to prevent
recovery. Any automated backup deletion should be investigated immediately.
Runbook: |
1. Find all automated backup deletion events by the user ARN in the past 24 hours to identify bulk deletion patterns
2. Check if this user has deleted automated backups in the past 90 days to determine if this is normal behavior
3. Look for database deletion or modification events from this user in the 6 hours after this backup deletion
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_WorkingWithAutomatedBackups.html
Reports:
MITRE ATT&CK:
- TA0040:T1485 # Data Destruction
- TA0040:T1490 # Inhibit System Recovery
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:dbiResourceId
- requestParameters:dbClusterResourceId
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameis one ofDeleteDBInstanceAutomatedBackup,DeleteDBClusterAutomatedBackuperrorCodeis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
dbiResourceId | requestParameters.dbiResourceId |
userName | userIdentity.userName |
Response runbook
1. Find all automated backup deletion events by the user ARN in the past 24 hours to identify bulk deletion patterns
2. Check if this user has deleted automated backups in the past 90 days to determine if this is normal behavior
3. Look for database deletion or modification events from this user in the 6 hours after this backup deletion
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "DeleteDBInstanceAutomatedBackup",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-16T10:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"dbiResourceId": "db-ABCDEF1234567890"
},
"responseElements": {
"allocatedStorage": 100,
"dBInstanceAutomatedBackupArn": "arn:aws:rds:us-east-1:123456789012:auto-backup:ab-1234567890abcdef",
"dbiResourceId": "db-ABCDEF1234567890",
"engine": "mysql",
"engineVersion": "8.0.35",
"status": "deleting"
},
"sourceIPAddress": "185.220.101.50",
"userAgent": "python-requests/2.28.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/CompromisedRole/attacker",
"principalId": "AIDAI23HXS3EXAMPLE:attacker",
"type": "AssumedRole"
}
}
AWS RDS Cluster Failover Initiated
#Detects when RDS cluster or global cluster failovers are manually initiated. Forced failovers cause brief service interruptions and may indicate disaster recovery testing, operational troubleshooting, or disruption attempts.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
failover_events = ["FailoverDBCluster", "FailoverGlobalCluster"]
if event.get("eventName") not in failover_events:
return False
return event.deep_get("errorCode") is None
def title(event):
event_name = event.get("eventName", "Unknown")
cluster_id = event.deep_get("requestParameters", "dBClusterIdentifier") or event.deep_get(
"requestParameters", "globalClusterIdentifier", default="<UNKNOWN>"
)
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
cluster_type = "Cluster" if event_name == "FailoverDBCluster" else "Global Cluster"
return f"RDS {cluster_type} Failover Initiated: [{cluster_id}] by [{user}]"
def alert_context(event):
context = aws_rule_context(event)
context["cluster_identifier"] = event.deep_get(
"requestParameters", "dBClusterIdentifier"
) or event.deep_get("requestParameters", "globalClusterIdentifier", default="N/A")
context["target_identifier"] = event.deep_get(
"requestParameters", "targetDBInstanceIdentifier"
) or event.deep_get("requestParameters", "targetDbClusterIdentifier", default="N/A")
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_cluster_failover.py
RuleID: "AWS.RDS.ClusterFailover"
DisplayName: "AWS RDS Cluster Failover Initiated"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Impact
- Availability
- Endpoint Denial of Service
- RDS
Severity: Medium
Description: >
Detects when RDS cluster or global cluster failovers are manually initiated. Forced
failovers cause brief service interruptions and may indicate disaster recovery testing,
operational troubleshooting, or disruption attempts.
Runbook: |
1. Find all cluster failover events by the user ARN in the past 24 hours
2. Check if this user has performed failovers in the past 90 days to determine if this is normal behavior
3. Look for database health or modification events in the 30 minutes before the failover
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-global-database-disaster-recovery.html
Reports:
MITRE ATT&CK:
- TA0040:T1499 # Endpoint Denial of Service
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:dBClusterIdentifier
- requestParameters:globalClusterIdentifier
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameis one ofFailoverDBCluster,FailoverGlobalClustererrorCodeis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
dBClusterIdentifier | requestParameters.dBClusterIdentifier |
userName | userIdentity.userName |
Response runbook
1. Find all cluster failover events by the user ARN in the past 24 hours
2. Check if this user has performed failovers in the past 90 days to determine if this is normal behavior
3. Look for database health or modification events in the 30 minutes before the failover
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "FailoverDBCluster",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-18T03:30:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"dBClusterIdentifier": "aurora-cluster-prod",
"targetDBInstanceIdentifier": "aurora-instance-2"
},
"responseElements": {
"dBClusterIdentifier": "aurora-cluster-prod",
"status": "failing-over"
},
"sourceIPAddress": "10.0.1.100",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/DBARole/admin",
"principalId": "AIDAI23HXS3EXAMPLE:admin",
"type": "AssumedRole"
}
}
AWS RDS Deletion Protection Disabled
#Detects when deletion protection is disabled on an RDS instance or cluster. This is often a precursor to database deletion and may indicate ransomware or data destruction attacks where attackers first disable protections before deleting resources.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment | |
| Impact |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Credential Access RDS Password reset (Splunk)
- AWS RDS DB Instance Made Public (Elastic)
- AWS RDS DB Instance or Cluster Deletion Protection Disabled (Elastic)
- AWS RDS DB Instance or Cluster Password Modified (Elastic)
- AWS RDS Instance Modified to be Publicly Accessible (Panther)
- AWS RDS Master Password Change (Sigma)
- AWS RDS Master Password Updated (Panther)
- AWS RDS Snapshot Deleted (Elastic)
Detection logic
from panther_aws_helpers import aws_rds_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
modify_events = ["ModifyDBInstance", "ModifyDBCluster"]
if event.get("eventName") not in modify_events:
return False
if event.deep_get("errorCode") is not None:
return False
deletion_protection = event.deep_get("requestParameters", "deletionProtection", default=None)
if deletion_protection is False:
return True
return False
def title(event):
event_name = event.get("eventName", "Unknown")
db_identifier = event.deep_get("requestParameters", "dBInstanceIdentifier") or event.deep_get(
"requestParameters", "dBClusterIdentifier", default="<UNKNOWN>"
)
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
resource_type = "Instance" if event_name == "ModifyDBInstance" else "Cluster"
return f"RDS {resource_type} Deletion Protection Disabled: [{db_identifier}] by [{user}]"
def dedup(event):
db_identifier = event.deep_get("requestParameters", "dBInstanceIdentifier") or event.deep_get(
"requestParameters", "dBClusterIdentifier", default="unknown"
)
account_id = event.deep_get("recipientAccountId", default="unknown")
region = event.get("awsRegion", "unknown")
return f"{account_id}:{region}:{db_identifier}"
def alert_context(event):
context = aws_rds_context(event)
context["deletion_protection"] = event.deep_get(
"requestParameters", "deletionProtection", default="N/A"
)
context["apply_immediately"] = event.deep_get(
"requestParameters", "applyImmediately", default="N/A"
)
backup_retention = event.deep_get("requestParameters", "backupRetentionPeriod", default=None)
if backup_retention is not None:
context["backup_retention_period"] = backup_retention
publicly_accessible = event.deep_get("requestParameters", "publiclyAccessible", default=None)
if publicly_accessible is not None:
context["publicly_accessible_changed"] = publicly_accessible
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_deletion_protection_disabled.py
RuleID: "AWS.RDS.DeletionProtectionDisabled"
DisplayName: "AWS RDS Deletion Protection Disabled"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Defense Evasion
- Impact
- Impair Defenses
- Inhibit System Recovery
- RDS
Severity: High
Description: >
Detects when deletion protection is disabled on an RDS instance or cluster. This is often
a precursor to database deletion and may indicate ransomware or data destruction attacks
where attackers first disable protections before deleting resources.
Runbook: |
1. Find all RDS modification events by the user ARN in the 24 hours before the alert
2. Check if this user has disabled deletion protection in the past 90 days to determine if this is normal behavior
3. Look for database deletion attempts from this user in the 2 hours after this modification
5. Immediately re-enable deletion protection if unauthorized using ModifyDBInstance or ModifyDBCluster with deletionProtection:true
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_DeleteInstance.html
Reports:
MITRE ATT&CK:
- TA0005:T1562 # Impair Defenses
- TA0040:T1490 # Inhibit System Recovery
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:dBInstanceIdentifier
- requestParameters:dBClusterIdentifier
- requestParameters:deletionProtection
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameis one ofModifyDBInstance,ModifyDBClustererrorCodeis emptyrequestParameters.deletionProtectionisfalse
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
requestParameters.deletionProtection | eq |
| field:"requestParameters.deletionProtection" kind:eq value:"false" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
dBInstanceIdentifier | requestParameters.dBInstanceIdentifier |
userName | userIdentity.userName |
Response runbook
1. Find all RDS modification events by the user ARN in the 24 hours before the alert
2. Check if this user has disabled deletion protection in the past 90 days to determine if this is normal behavior
3. Look for database deletion attempts from this user in the 2 hours after this modification
5. Immediately re-enable deletion protection if unauthorized using ModifyDBInstance or ModifyDBCluster with deletionProtection:true
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "ModifyDBInstance",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-15T18:20:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"applyImmediately": true,
"dBInstanceIdentifier": "production-mysql",
"deletionProtection": false
},
"responseElements": {
"dBInstanceIdentifier": "production-mysql",
"dBInstanceStatus": "modifying",
"deletionProtection": false,
"pendingModifiedValues": {
"deletionProtection": false
}
},
"sourceIPAddress": "203.0.113.45",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/PowerUserRole/user",
"principalId": "AIDAI23HXS3EXAMPLE:user",
"sessionContext": {
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/PowerUserRole",
"principalId": "AIDAI23HXS3EXAMPLE",
"type": "Role",
"userName": "PowerUserRole"
}
},
"type": "AssumedRole"
}
}
AWS RDS Instance Backup
#This Policy ensures that RDS Instances have Backups enabled. Backups are an important aspect of disaster recovery that can protect sensitive data from destruction.
Detection logic
def policy(event):
return event.get("BackupRetentionPeriod") != 0
Rule specification
AnalysisType: policy
Filename: aws_rds_instance_backup.py
PolicyID: "AWS.RDS.InstanceBackup"
DisplayName: "AWS RDS Instance Backup"
Enabled: true
ResourceTypes:
- AWS.RDS.Instance
Tags:
- AWS
- Availability
Severity: Medium
Description: >
This Policy ensures that RDS Instances have Backups enabled. Backups are an important aspect
of disaster recovery that can protect sensitive data from destruction.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-rds-instance-has-backups-enabled
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_WorkingWithAutomatedBackups.html
Stages and Predicates
Flags AWS.RDS.Instance resources when the condition below holds.
Condition
BackupRetentionPeriodis0
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
BackupRetentionPeriod | ne | 0 | excludes:BackupRetentionPeriod field:"BackupRetentionPeriod" value:"0" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
BackupRetentionPeriod | eq |
| field:"BackupRetentionPeriod" kind:eq value:"0" |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-rds-instance-has-backups-enabled
AWS RDS Instance Encryption
#This policy validates that RDS instances have encryption enabled.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
def policy(resource):
return resource["KmsKeyId"] is not None
Rule specification
AnalysisType: policy
Filename: aws_rds_instance_encryption.py
PolicyID: "AWS.RDS.Instance.Encryption"
DisplayName: "AWS RDS Instance Encryption"
Enabled: true
ResourceTypes:
- AWS.RDS.Instance
Tags:
- AWS
- Data Protection
- Collection:Data From Cloud Storage Object
Reports:
PCI:
- 3.4
MITRE ATT&CK:
- TA0009:T1530
Severity: High
Description: >
This policy validates that RDS instances have encryption enabled.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-rds-instance-has-storage-encrypted
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.Encryption.html
Stages and Predicates
Flags AWS.RDS.Instance resources when the condition below holds.
Condition
KmsKeyIdis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
KmsKeyId | is_not_null | excludes:KmsKeyId |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
KmsKeyId | is_null | field:"KmsKeyId" kind:is_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-rds-instance-has-storage-encrypted
AWS RDS Instance Has Acceptable Backup Retention Period
#This policy validates that RDS instances are configured with a backup retention period that is acceptable to company policy. This ensures for both compliance and security reasons that records are kept for a minimum period of time, and for compliance and performance reasons that records are not kept indefinitely.
Detection logic
MAX_RETENTION_DAYS = 180
MIN_RETENTION_DAYS = 7
def policy(resource):
return MIN_RETENTION_DAYS <= resource["BackupRetentionPeriod"] <= MAX_RETENTION_DAYS
Rule specification
AnalysisType: policy
Filename: aws_rds_instance_backup_retention_acceptable.py
PolicyID: "AWS.RDS.InstanceBackupRetentionAcceptable"
DisplayName: "AWS RDS Instance Has Acceptable Backup Retention Period"
Enabled: false
ResourceTypes:
- AWS.RDS.Instance
Tags:
- AWS
- Database
- PCI
Reports:
PCI:
- 3.1
Severity: Low
Description: >
This policy validates that RDS instances are configured with a backup retention period that is acceptable to company policy. This ensures for both compliance and security reasons that records are kept for a minimum period of time, and for compliance and performance reasons that records are not kept indefinitely.
Runbook: >
Adjust the backup retention period to a timeframe within company policy.
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_WorkingWithAutomatedBackups.html
Stages and Predicates
Flags AWS.RDS.Instance resources when any of the conditions below holds.
Condition
any of:
BackupRetentionPeriodis less than7BackupRetentionPeriodis greater than180
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
BackupRetentionPeriod | ge | 7 | excludes:BackupRetentionPeriod field:"BackupRetentionPeriod" value:"7" |
BackupRetentionPeriod | le | 180 | excludes:BackupRetentionPeriod field:"BackupRetentionPeriod" value:"180" |
Indicators
These rows show field, operator, and value matches.
Response runbook
Adjust the backup retention period to a timeframe within company policy.
AWS RDS Instance High Availability
#This Policy ensures that RDS Instances have are running in High Availability mode to provide redundancy in the event of an operational failure. For Aurora, storage is replicated across all the Availability Zones and doesn't require this setting.
Detection logic
def policy(resource):
if resource["MultiAZ"] is False and resource["StorageType"] == "aurora":
return True
# Explicit check for True to avoid returning NoneType
return resource["MultiAZ"] is True
Rule specification
AnalysisType: policy
Filename: aws_rds_instance_high_availability.py
PolicyID: "AWS.RDS.InstanceHighAvailability"
DisplayName: "AWS RDS Instance High Availability"
Enabled: true
ResourceTypes:
- AWS.RDS.Instance
Tags:
- AWS
- Availability
Severity: Low
Description: >
This Policy ensures that RDS Instances have are running in High Availability mode to provide
redundancy in the event of an operational failure. For Aurora, storage is replicated across all the Availability Zones and doesn't require this setting.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-rds-instance-has-high-availability-configured
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.MultiAZ.html
Stages and Predicates
Flags AWS.RDS.Instance resources when all of the conditions below hold.
Condition
any of:
MultiAZis notfalseStorageTypeis notaurora
MultiAZis nottrue
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
MultiAZ | eq | false | excludes:MultiAZ field:"MultiAZ" value:"false" |
StorageType | eq | aurora | excludes:StorageType field:"StorageType" value:"aurora" |
MultiAZ | eq | true | excludes:MultiAZ field:"MultiAZ" value:"true" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
MultiAZ | ne |
| field:"MultiAZ" kind:ne |
StorageType | ne |
| field:"StorageType" kind:ne value:"aurora" |
Response runbook
AWS RDS Instance Minor Version Upgrades
#If you want Amazon RDS to upgrade the DB engine version of a database automatically, you can enable auto minor version upgrades for the database.
Detection logic
def policy(resource):
return resource["AutoMinorVersionUpgrade"]
Rule specification
AnalysisType: policy
Filename: aws_rds_instance_auto_minor_version_upgrade_enabled.py
PolicyID: "AWS.RDS.Instance.AutoMinorVersionUpgradeEnabled"
DisplayName: "AWS RDS Instance Minor Version Upgrades"
Enabled: true
ResourceTypes:
- AWS.RDS.Instance
Tags:
- AWS
- Panther
- PCI
Reports:
PCI:
- 6.2
Severity: Low
Description: >
If you want Amazon RDS to upgrade the DB engine version of a database automatically,
you can enable auto minor version upgrades for the database.
Runbook: |
For major version upgrades, you must manually modify the DB engine version through the
AWS Management Console, AWS CLI, or RDS API. For minor version upgrades,
you can manually modify the engine version, or you can choose to enable auto minor version upgrades.
Reference: https://amzn.to/2L8POnD
Stages and Predicates
Flags AWS.RDS.Instance resources when the condition below holds.
Condition
AutoMinorVersionUpgradeis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
AutoMinorVersionUpgrade | is_not_null | excludes:AutoMinorVersionUpgrade |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
AutoMinorVersionUpgrade | is_null | field:"AutoMinorVersionUpgrade" kind:is_null |
Response runbook
For major version upgrades, you must manually modify the DB engine version through the
AWS Management Console, AWS CLI, or RDS API. For minor version upgrades,
you can manually modify the engine version, or you can choose to enable auto minor version upgrades.
AWS RDS Instance Modified to be Publicly Accessible
#Detects when an RDS instance or cluster is modified to become publicly accessible. This exposes the database to the internet and is used by attackers for persistence or data exfiltration. This detects the modification event in real-time, unlike static policy checks.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Credential Access RDS Password reset (Splunk)
- AWS RDS DB Instance Made Public (Elastic)
- AWS RDS DB Instance or Cluster Deletion Protection Disabled (Elastic)
- AWS RDS DB Instance or Cluster Password Modified (Elastic)
- AWS RDS Deletion Protection Disabled (Panther)
- AWS RDS Master Password Change (Sigma)
- AWS RDS Master Password Updated (Panther)
- AWS RDS Snapshot Deleted (Elastic)
Detection logic
from panther_aws_helpers import aws_rds_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
modify_events = ["ModifyDBInstance", "ModifyDBCluster"]
if event.get("eventName") not in modify_events:
return False
if event.deep_get("errorCode") is not None:
return False
publicly_accessible = event.deep_get("requestParameters", "publiclyAccessible", default=None)
if publicly_accessible is True:
return True
return False
def title(event):
event_name = event.get("eventName", "Unknown")
db_identifier = event.deep_get("requestParameters", "dBInstanceIdentifier") or event.deep_get(
"requestParameters", "dBClusterIdentifier", default="<UNKNOWN>"
)
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
resource_type = "Instance" if event_name == "ModifyDBInstance" else "Cluster"
return f"RDS {resource_type} Made Public: [{db_identifier}] by [{user}]"
def alert_context(event):
context = aws_rds_context(event)
context["publicly_accessible"] = event.deep_get(
"requestParameters", "publiclyAccessible", default="N/A"
)
context["apply_immediately"] = event.deep_get(
"requestParameters", "applyImmediately", default="N/A"
)
vpc_security_groups = event.deep_get("requestParameters", "vPCSecurityGroupIds", default=None)
if vpc_security_groups:
context["vpc_security_groups_modified"] = vpc_security_groups
subnet_group = event.deep_get("requestParameters", "dBSubnetGroupName")
if subnet_group:
context["subnet_group_modified"] = subnet_group
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_instance_made_public.py
RuleID: "AWS.RDS.InstanceMadePublic"
DisplayName: "AWS RDS Instance Modified to be Publicly Accessible"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Persistence
- Defense Evasion
- Initial Access
- Account Manipulation
- Disable or Modify Cloud Firewall
- External Remote Services
- RDS
Severity: Critical
Description: >
Detects when an RDS instance or cluster is modified to become publicly accessible. This exposes
the database to the internet and is used by attackers for persistence or data exfiltration.
This detects the modification event in real-time, unlike static policy checks.
Runbook: |
1. Find all RDS modification events by the user ARN in the 24 hours before the alert
2. Check if this database has been made public before by searching for ModifyDBInstance events with publiclyAccessible in the past 90 days
3. Look for database connection attempts from external IPs in the 6 hours after this modification
3. Find all API calls from the sourceIPAddress in the 6 hours before and after the alert to identify other suspicious modifications
4. Immediately revert publiclyAccessible to false if unauthorized, then review VPC security groups for overly permissive rules (0.0.0.0/0)
5. Check RDS database logs for any access attempts from external IPs in the time window after this modification
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_VPC.WorkingWithRDSInstanceinaVPC.html
Reports:
MITRE ATT&CK:
- TA0003:T1098 # Account Manipulation
- TA0005:T1562.007 # Disable or Modify Cloud Firewall
- TA0001:T1133 # External Remote Services
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:dBInstanceIdentifier
- requestParameters:dBClusterIdentifier
- requestParameters:publiclyAccessible
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameis one ofModifyDBInstance,ModifyDBClustererrorCodeis emptyrequestParameters.publiclyAccessibleistrue
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
requestParameters.publiclyAccessible | eq |
| field:"requestParameters.publiclyAccessible" kind:eq value:"true" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
dBInstanceIdentifier | requestParameters.dBInstanceIdentifier |
userName | userIdentity.userName |
Response runbook
1. Find all RDS modification events by the user ARN in the 24 hours before the alert
2. Check if this database has been made public before by searching for ModifyDBInstance events with publiclyAccessible in the past 90 days
3. Look for database connection attempts from external IPs in the 6 hours after this modification
3. Find all API calls from the sourceIPAddress in the 6 hours before and after the alert to identify other suspicious modifications
4. Immediately revert publiclyAccessible to false if unauthorized, then review VPC security groups for overly permissive rules (0.0.0.0/0)
5. Check RDS database logs for any access attempts from external IPs in the time window after this modification
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "ModifyDBInstance",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-15T16:45:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"applyImmediately": true,
"dBInstanceIdentifier": "production-db",
"publiclyAccessible": true
},
"responseElements": {
"dBInstanceIdentifier": "production-db",
"dBInstanceStatus": "modifying",
"pendingModifiedValues": {
"publiclyAccessible": true
},
"publiclyAccessible": true
},
"sourceIPAddress": "203.0.113.45",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/DeveloperRole/developer",
"principalId": "AIDAI23HXS3EXAMPLE:developer",
"sessionContext": {
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/DeveloperRole",
"principalId": "AIDAI23HXS3EXAMPLE",
"type": "Role",
"userName": "DeveloperRole"
}
},
"type": "AssumedRole"
}
}
AWS RDS Instance or Cluster Deleted
#Detects RDS database instance or cluster deletion. Deletions that skip final snapshots result in permanent data loss and may indicate ransomware, insider threats, or compromised credentials being used to destroy data.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rds_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
deletion_events = ["DeleteDBInstance", "DeleteDBCluster"]
if event.get("eventName") not in deletion_events:
return False
return event.deep_get("errorCode") is None
def title(event):
event_name = event.get("eventName", "Unknown")
db_identifier = event.deep_get("requestParameters", "dBInstanceIdentifier") or event.deep_get(
"requestParameters", "dBClusterIdentifier", default="<UNKNOWN>"
)
skip_snapshot = event.deep_get("requestParameters", "skipFinalSnapshot", default=False)
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
snapshot_warning = " [NO FINAL SNAPSHOT]" if skip_snapshot else ""
if event_name == "DeleteDBInstance":
return f"RDS Instance Deleted: [{db_identifier}] by [{user}]{snapshot_warning}"
return f"RDS Cluster Deleted: [{db_identifier}] by [{user}]{snapshot_warning}"
def dedup(event):
db_identifier = event.deep_get("requestParameters", "dBInstanceIdentifier") or event.deep_get(
"requestParameters", "dBClusterIdentifier", default="unknown"
)
account_id = event.deep_get("recipientAccountId", default="unknown")
region = event.get("awsRegion", "unknown")
return f"{account_id}:{region}:{db_identifier}"
def alert_context(event):
context = aws_rds_context(event)
context["skip_final_snapshot"] = event.deep_get(
"requestParameters", "skipFinalSnapshot", default=False
)
context["final_snapshot_identifier"] = event.deep_get(
"requestParameters", "finalDBSnapshotIdentifier"
) or event.deep_get("requestParameters", "finalDBClusterSnapshotIdentifier", default="N/A")
context["delete_automated_backups"] = event.deep_get(
"requestParameters", "deleteAutomatedBackups", default="N/A"
)
return context
def severity(event):
skip_snapshot = event.deep_get("requestParameters", "skipFinalSnapshot", default=False)
delete_backups = event.deep_get("requestParameters", "deleteAutomatedBackups", default=False)
if skip_snapshot or delete_backups:
return "CRITICAL"
return "HIGH"
Rule specification
AnalysisType: rule
Filename: aws_rds_instance_deletion.py
RuleID: "AWS.RDS.InstanceDeletion"
DisplayName: "AWS RDS Instance or Cluster Deleted"
Enabled: true
Status: Experimental
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Impact
- Data Destruction
- RDS
Severity: High
Description: >
Detects RDS database instance or cluster deletion. Deletions that skip final snapshots
result in permanent data loss and may indicate ransomware, insider threats, or
compromised credentials being used to destroy data.
Runbook: |
1. Find all RDS API calls by the user ARN in the 48 hours before the alert to identify precursor activities
2. Check if this user has deleted databases in the past 90 days to determine if this is unusual behavior
3. Look for deletion protection changes from this user in the 24 hours before the deletion
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_DeleteInstance.html
Reports:
MITRE ATT&CK:
- TA0040:T1485 # Data Destruction
- TA0040:T1531 # Account Access Removal
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:dBInstanceIdentifier
- requestParameters:dBClusterIdentifier
- requestParameters:skipFinalSnapshot
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameis one ofDeleteDBInstance,DeleteDBClustererrorCodeis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
dBInstanceIdentifier | requestParameters.dBInstanceIdentifier |
userName | userIdentity.userName |
Response runbook
1. Find all RDS API calls by the user ARN in the 48 hours before the alert to identify precursor activities
2. Check if this user has deleted databases in the past 90 days to determine if this is unusual behavior
3. Look for deletion protection changes from this user in the 24 hours before the deletion
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "DeleteDBInstance",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-15T14:30:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"dBInstanceIdentifier": "production-mysql-db",
"deleteAutomatedBackups": false,
"finalDBSnapshotIdentifier": "production-mysql-db-final-snapshot-2024-01-15",
"skipFinalSnapshot": false
},
"responseElements": {
"dBInstanceArn": "arn:aws:rds:us-east-1:123456789012:db:production-mysql-db",
"dBInstanceIdentifier": "production-mysql-db",
"dBInstanceStatus": "deleting"
},
"sourceIPAddress": "10.0.1.100",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/DBARole/admin",
"principalId": "AIDAI23HXS3EXAMPLE:admin",
"sessionContext": {
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/DBARole",
"principalId": "AIDAI23HXS3EXAMPLE",
"type": "Role",
"userName": "DBARole"
}
},
"type": "AssumedRole"
}
}
AWS RDS Instance or Cluster Rebooted
#Detects when RDS instances, clusters, or shard groups are rebooted. Unexpected reboots cause service disruption and may indicate DoS attempts, unauthorized testing, or operational issues requiring investigation.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
reboot_events = ["RebootDBInstance", "RebootDBCluster", "RebootDBShardGroup"]
if event.get("eventName") not in reboot_events:
return False
return event.deep_get("errorCode") is None
def title(event):
event_name = event.get("eventName", "Unknown")
db_identifier = (
event.deep_get("requestParameters", "dBInstanceIdentifier")
or event.deep_get("requestParameters", "dBClusterIdentifier")
or event.deep_get("requestParameters", "dBShardGroupIdentifier", default="<UNKNOWN>")
)
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
resource_type = (
"Instance"
if event_name == "RebootDBInstance"
else "Cluster" if event_name == "RebootDBCluster" else "Shard Group"
)
return f"RDS {resource_type} Rebooted: [{db_identifier}] by [{user}]"
def alert_context(event):
context = aws_rule_context(event)
context["resource_identifier"] = (
event.deep_get("requestParameters", "dBInstanceIdentifier")
or event.deep_get("requestParameters", "dBClusterIdentifier")
or event.deep_get("requestParameters", "dBShardGroupIdentifier", default="N/A")
)
context["force_failover"] = event.deep_get("requestParameters", "forceFailover", default="N/A")
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_instance_rebooted.py
RuleID: "AWS.RDS.InstanceRebooted"
DisplayName: "AWS RDS Instance or Cluster Rebooted"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Impact
- Availability
- Endpoint Denial of Service
- RDS
Severity: Medium
Description: >
Detects when RDS instances, clusters, or shard groups are rebooted. Unexpected reboots
cause service disruption and may indicate DoS attempts, unauthorized testing, or
operational issues requiring investigation.
Runbook: |
1. Find all database reboot events by the user ARN in the past 24 hours to identify bulk reboot patterns
2. Check if this user has rebooted databases in the past 90 days to determine if this is normal behavior
3. Look for database modification events from this user in the 30 minutes before the reboot
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_RebootInstance.html
Reports:
MITRE ATT&CK:
- TA0040:T1499 # Endpoint Denial of Service
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:dBInstanceIdentifier
- requestParameters:dBClusterIdentifier
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameis one ofRebootDBInstance,RebootDBCluster,RebootDBShardGrouperrorCodeis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
dBInstanceIdentifier | requestParameters.dBInstanceIdentifier |
userName | userIdentity.userName |
Response runbook
1. Find all database reboot events by the user ARN in the past 24 hours to identify bulk reboot patterns
2. Check if this user has rebooted databases in the past 90 days to determine if this is normal behavior
3. Look for database modification events from this user in the 30 minutes before the reboot
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "RebootDBInstance",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-18T02:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"dBInstanceIdentifier": "production-mysql",
"forceFailover": false
},
"responseElements": {
"dBInstanceIdentifier": "production-mysql",
"dBInstanceStatus": "rebooting"
},
"sourceIPAddress": "10.0.1.100",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/DBARole/admin",
"principalId": "AIDAI23HXS3EXAMPLE:admin",
"type": "AssumedRole"
}
}
AWS RDS Instance Public Access
#This Policy checks that an RDS Instance is not accessible from the public internet.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Detection logic
def policy(resource):
return not resource["PubliclyAccessible"]
Rule specification
AnalysisType: policy
Filename: aws_rds_instance_public_access.py
PolicyID: "AWS.RDS.Instance.PublicAccess"
DisplayName: "AWS RDS Instance Public Access"
Enabled: true
ResourceTypes:
- AWS.RDS.Instance
Tags:
- AWS
- Data Protection
- Exfiltration:Exfiltration Over Web Service
Reports:
MITRE ATT&CK:
- TA0010:T1567
Severity: High
Description: >
This Policy checks that an RDS Instance is not accessible from the public internet.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-rds-instance-is-not-publicly-accessible
Reference: https://docs.aws.amazon.com/en_pv/AmazonRDS/latest/UserGuide/USER_VPC.Scenarios.html
Stages and Predicates
Flags AWS.RDS.Instance resources when the condition below holds.
Condition
PubliclyAccessibleis present
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
PubliclyAccessible | is_null | excludes:PubliclyAccessible |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
PubliclyAccessible | is_not_null | field:"PubliclyAccessible" kind:is_not_null |
Response runbook
AWS RDS Instance Snapshot Public Access
#This policy validates that RDS Instance snapshots are not publicly restorable. This would allow anyone to restore an old version of your database and have full access to its contents.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Detection logic
from panther_base_helpers import listify
def policy(resource):
# Check if this instance has snapshots
if resource["SnapshotAttributes"] is None:
return True
# Check that no snapshots are able to be restored by all (i.e. are public)
for snapshot_attrs in resource["SnapshotAttributes"]:
for snapshot_attr in snapshot_attrs["DBSnapshotAttributes"]:
if (
snapshot_attr["AttributeName"] == "restore"
and snapshot_attr["AttributeValues"] is not None
and "all" in listify(snapshot_attr["AttributeValues"])
):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_rds_instance_snapshot_public_access.py
PolicyID: "AWS.RDS.Instance.SnapshotPublicAccess"
DisplayName: "AWS RDS Instance Snapshot Public Access"
Enabled: true
ResourceTypes:
- AWS.RDS.Instance
Tags:
- AWS
- Data Protection
- Exfiltration:Exfiltration Over Web Service
Reports:
MITRE ATT&CK:
- TA0010:T1567
Severity: Critical
Description: >
This policy validates that RDS Instance snapshots are not publicly restorable. This would allow anyone to restore an old version of your database and have full access to its contents.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-rds-instance-snapshots-are-not-publicly-accessible
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_WorkingWithAutomatedBackups.html
Stages and Predicates
Flags AWS.RDS.Instance resources when the condition below holds.
Condition
SnapshotAttributesis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
SnapshotAttributes | is_null | excludes:SnapshotAttributes |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
SnapshotAttributes | is_not_null | field:"SnapshotAttributes" kind:is_not_null |
Response runbook
AWS RDS Log File Downloaded
#Detects when RDS database log files are downloaded. Log files may contain credentials, sensitive queries, or application secrets. Bulk downloads from unusual locations may indicate credential harvesting or data reconnaissance.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event DownloadDBLogFilePortion: Downloads all or a portion of the specified log file. |
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
if event.get("eventName") != "DownloadDBLogFilePortion":
return False
return event.deep_get("errorCode") is None
def title(event):
db_identifier = event.deep_get("requestParameters", "dBInstanceIdentifier", default="<UNKNOWN>")
log_file = event.deep_get("requestParameters", "logFileName", default="<UNKNOWN_FILE>")
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
return f"RDS Log Downloaded: [{log_file}] from [{db_identifier}] by [{user}]"
def dedup(event):
db_identifier = event.deep_get("requestParameters", "dBInstanceIdentifier", default="unknown")
log_file = event.deep_get("requestParameters", "logFileName", default="unknown")
account_id = event.deep_get("recipientAccountId", default="unknown")
region = event.get("awsRegion", "unknown")
return f"{account_id}:{region}:{db_identifier}:{log_file}"
def alert_context(event):
context = aws_rule_context(event)
context["db_instance_identifier"] = event.deep_get(
"requestParameters", "dBInstanceIdentifier", default="N/A"
)
context["log_file_name"] = event.deep_get("requestParameters", "logFileName", default="N/A")
context["marker"] = event.deep_get("requestParameters", "marker", default="N/A")
context["number_of_lines"] = event.deep_get("requestParameters", "numberOfLines", default="N/A")
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_log_file_downloaded.py
RuleID: "AWS.RDS.LogFileDownloaded"
DisplayName: "AWS RDS Log File Downloaded"
Enabled: true
Status: Experimental
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Credential Access
- Discovery
- Credentials in Files
- RDS
Severity: Low
Description: >
Detects when RDS database log files are downloaded. Log files may contain credentials,
sensitive queries, or application secrets. Bulk downloads from unusual locations may
indicate credential harvesting or data reconnaissance.
Runbook: |
1. Find all log file download events by the user ARN in the past 6 hours to identify bulk download patterns
2. Check if the source IP address matches the user's normal access patterns from the past 30 days
3. Look for database access or modification events from this user in the 48 hours before the log download
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_LogAccess.html
Reports:
MITRE ATT&CK:
- TA0006:T1552.001 # Credentials in Files
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:dBInstanceIdentifier
- requestParameters:logFileName
- sourceIPAddress
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameisDownloadDBLogFilePortionerrorCodeis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | eq |
| field:"aws::eventName" kind:eq value:"DownloadDBLogFilePortion" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
logFileName | requestParameters.logFileName |
dBInstanceIdentifier | requestParameters.dBInstanceIdentifier |
userName | userIdentity.userName |
Response runbook
1. Find all log file download events by the user ARN in the past 6 hours to identify bulk download patterns
2. Check if the source IP address matches the user's normal access patterns from the past 30 days
3. Look for database access or modification events from this user in the 48 hours before the log download
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "DownloadDBLogFilePortion",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-18T06:15:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": true,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"dBInstanceIdentifier": "production-mysql",
"logFileName": "error/mysql-error.log",
"marker": "0",
"numberOfLines": 1000
},
"responseElements": {
"additionalDataPending": true,
"logFileData": "[REDACTED]",
"marker": "1000"
},
"sourceIPAddress": "10.0.1.100",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/DBARole/dba",
"principalId": "AIDAI23HXS3EXAMPLE:dba",
"type": "AssumedRole"
}
}
AWS RDS Manual/Public Snapshot Created
#A manual snapshot of an RDS database was created. An attacker may use this to exfiltrate the DB contents to another account; use this as a correlation rule.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event CreateDBSnapshot: Creates a snapshot of a DB instance. |
Rules detecting the same action
These rules filter on the same operation.
- AWS RDS DB Snapshot Created (Elastic)
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return all(
[
event.get("eventSource", "") == "rds.amazonaws.com",
event.get("eventName", "") == "CreateDBSnapshot",
event.deep_get("responseElements", "snapshotType") in {"manual", "public"},
]
)
def title(event):
account_id = event.get("recipientAccountId", "")
rds_instance_id = event.deep_get("responseElements", "dBInstanceIdentifier")
return f"Manual RDS Snapshot Created in [{account_id}] for RDS instance [{rds_instance_id}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_rds_manual_snapshot_created.py
RuleID: "AWS.RDS.ManualSnapshotCreated"
DisplayName: "AWS RDS Manual/Public Snapshot Created"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Exfiltration
- Transfer Data to Cloud Account
Reports:
MITRE ATT&CK:
- TA0010:T1537
Severity: Low
Description: >
A manual snapshot of an RDS database was created.
An attacker may use this to exfiltrate the DB contents to another account; use this as a correlation rule.
Runbook: >
Ensure the snapshot was shared with an allowed AWS account. If not, delete the snapshot and quarantine the compromised IAM user.
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_CreateSnapshot.html
SummaryAttributes:
- eventSource
- recipientAccountId
- awsRegion
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameisCreateDBSnapshotresponseElements.snapshotTypeis one ofmanual,public
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"CreateDBSnapshot" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
responseElements.snapshotType | in |
| field:"responseElements.snapshotType" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
dBInstanceIdentifier | responseElements.dBInstanceIdentifier |
Response runbook
Ensure the snapshot was shared with an allowed AWS account. If not, delete the snapshot and quarantine the compromised IAM user.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "c665b42c-89b4-4072-ad71-0f9c8d50f649",
"eventName": "CreateDBSnapshot",
"eventSource": "rds.amazonaws.com",
"eventTime": "2023-12-08T14:55:19Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "e5fd8d41-db7c-45df-a21a-f9cff8c19755",
"requestParameters": {
"dBInstanceIdentifier": "terraform-20231208145149286600000001",
"dBSnapshotIdentifier": "exfiltration"
},
"responseElements": {
"allocatedStorage": 10,
"availabilityZone": "us-west-2b",
"dBInstanceIdentifier": "terraform-20231208145149286600000001",
"dBSnapshotArn": "arn:aws:rds:us-west-2:123456789012:snapshot:exfiltration",
"dBSnapshotIdentifier": "exfiltration",
"dbiResourceId": "db-TYZSSMTWIABIR6QKKFGI55XKJQ",
"dedicatedLogVolume": false,
"encrypted": false,
"engine": "mysql",
"engineVersion": "8.0.33",
"iAMDatabaseAuthenticationEnabled": false,
"instanceCreateTime": "Dec 8, 2023 2:55:17 PM",
"licenseModel": "general-public-license",
"masterUsername": "admin",
"optionGroupName": "default:mysql-8-0",
"percentProgress": 0,
"port": 3306,
"processorFeatures": [],
"snapshotTarget": "region",
"snapshotType": "manual",
"status": "creating",
"storageThroughput": 0,
"storageType": "gp2",
"vpcId": "vpc-0c9c141888d129377"
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "rds.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "APN/1.0 HashiCorp/1.0 Terraform/1.1.2 (+https://www.terraform.io) terraform-provider-aws/3.76.1 (+https://registry.terraform.io/providers/hashicorp/aws) aws-sdk-go/1.44.157 (go1.19.3; darwin; arm64) 68319f60-9dec-43b2-9702-de3a08c9d8a3 HashiCorp-terraform-exec/0.17.3",
"userIdentity": {
"accessKeyId": "ASIAFFA5AFEC02FFCD8ED",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/ARole/fake.user",
"principalId": "AROA2DFDF0C1FDFCAD2B2:fake.user",
"sessionContext": {
"attributes": {
"creationDate": "2023-12-08T13:53:48Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/us-west-2/ARole",
"principalId": "AROA2DFDF0C1FDFCAD2B2",
"type": "Role",
"userName": "ARole"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS RDS Master Password Updated
#A sensitive database operation that should be performed carefully or rarely
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Credential Access RDS Password reset (Splunk)
- AWS RDS DB Instance Made Public (Elastic)
- AWS RDS DB Instance or Cluster Deletion Protection Disabled (Elastic)
- AWS RDS DB Instance or Cluster Password Modified (Elastic)
- AWS RDS Deletion Protection Disabled (Panther)
- AWS RDS Instance Modified to be Publicly Accessible (Panther)
- AWS RDS Master Password Change (Sigma)
- AWS RDS Snapshot Deleted (Elastic)
Detection logic
def rule(event):
return (
event.get("eventName") == "ModifyDBInstance"
and event.get("eventSource") == "rds.amazonaws.com"
and bool(event.deep_get("responseElements", "pendingModifiedValues", "masterUserPassword"))
)
def title(event):
return f"RDS Master Password Updated on [{event.deep_get('responseElements', 'dBInstanceArn')}]"
Rule specification
AnalysisType: rule
Description: A sensitive database operation that should be performed carefully or rarely
DisplayName: "AWS RDS Master Password Updated"
Enabled: true
Filename: aws_rds_master_pass_updated.py
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.DBInstance.Modifying.html
Severity: Low
DedupPeriodMinutes: 60
Reports:
MITRE ATT&CK:
- TA0003:T1098
SummaryAttributes:
- awsRegion
- userIdentity:arn
- responseElements:dBInstanceIdentifier
- p_any_aws_arns
- p_any_aws_account_ids
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.RDS.MasterPasswordUpdated"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisModifyDBInstanceeventSourceisrds.amazonaws.comresponseElements.pendingModifiedValues.masterUserPasswordis present
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"ModifyDBInstance" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
responseElements.pendingModifiedValues.masterUserPassword | is_not_null | field:"responseElements.pendingModifiedValues.masterUserPassword" kind:is_not_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
dBInstanceArn | responseElements.dBInstanceArn |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-1",
"eventCategory": "Management",
"eventID": "09191e37-4632-4722-82bf-50288436cf47",
"eventName": "ModifyDBInstance",
"eventSource": "rds.amazonaws.com",
"eventTime": "2022-09-24 00:28:15",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_any_aws_account_ids": [
"123456789012"
],
"p_any_aws_arns": [
"arn:aws:iam::123456789012:role/Admin",
"arn:aws:kms:us-west-1:123456789012:key/e2c16323-1c31-45fb-adda-07e5c9f78997",
"arn:aws:rds:us-west-1:123456789012:db:my-database",
"arn:aws:sts::123456789012:assumed-role/Admin/Jack"
],
"p_any_domain_names": [
"AWS Internal"
],
"p_any_trace_ids": [
"ASIASWJRT64ZWCAMGCWI"
],
"p_event_time": "2022-09-24 00:28:15",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2022-09-24 00:32:43.679",
"p_row_id": "eea2890fafffb7b88fae80cb138a08",
"p_source_id": "b00eb354-da7a-49dd-9cc6-32535e32096a",
"p_source_label": "CloudTrail",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "93fba047-0282-4c53-b3a6-1c3bb684f563",
"requestParameters": {
"allowMajorVersionUpgrade": false,
"applyImmediately": true,
"dBInstanceIdentifier": "my-database",
"masterUserPassword": "****",
"maxAllocatedStorage": 1000
},
"responseElements": {
"allocatedStorage": 20,
"associatedRoles": [],
"autoMinorVersionUpgrade": true,
"availabilityZone": "us-west-1b",
"backupRetentionPeriod": 0,
"backupTarget": "region",
"cACertificateIdentifier": "rds-ca-2019",
"copyTagsToSnapshot": true,
"customerOwnedIpEnabled": false,
"dBInstanceArn": "arn:aws:rds:us-west-1:123456789012:db:my-database",
"dBInstanceClass": "db.t3.micro",
"dBInstanceIdentifier": "my-database",
"dBInstanceStatus": "available",
"dBName": "test",
"dBParameterGroups": [
{
"dBParameterGroupName": "default.mysql8.0",
"parameterApplyStatus": "in-sync"
}
],
"dBSecurityGroups": [],
"dBSubnetGroup": {
"dBSubnetGroupDescription": "Created from the RDS Management Console",
"dBSubnetGroupName": "default-vpc-f9999999",
"subnetGroupStatus": "Complete",
"subnets": [
{
"subnetAvailabilityZone": {
"name": "us-west-1c"
},
"subnetIdentifier": "subnet-8cb458ea",
"subnetOutpost": {},
"subnetStatus": "Active"
},
{
"subnetAvailabilityZone": {
"name": "us-west-1b"
},
"subnetIdentifier": "subnet-8382bbd8",
"subnetOutpost": {},
"subnetStatus": "Active"
}
],
"vpcId": "vpc-f9999999"
},
"dbInstancePort": 0,
"dbiResourceId": "db-FEVDSUCWJ43PXONVT6ZU2TK4WY",
"deletionProtection": false,
"domainMemberships": [],
"enabledCloudwatchLogsExports": [
"audit",
"error",
"general"
],
"endpoint": {
"address": "my-database.cbsugyyyyyyy.us-west-1.rds.amazonaws.com",
"hostedZoneId": "Z10WI91S59XXQN",
"port": 3306
},
"engine": "mysql",
"engineVersion": "8.0.28",
"httpEndpointEnabled": false,
"iAMDatabaseAuthenticationEnabled": false,
"instanceCreateTime": "Sep 23, 2022 11:25:46 PM",
"kmsKeyId": "arn:aws:kms:us-west-1:123456789012:key/e2c16323-1c31-45fb-adda-07e5c9f78997",
"licenseModel": "general-public-license",
"masterUsername": "admin",
"maxAllocatedStorage": 1000,
"monitoringInterval": 0,
"multiAZ": false,
"networkType": "IPV4",
"optionGroupMemberships": [
{
"optionGroupName": "default:mysql-8-0",
"status": "in-sync"
}
],
"pendingModifiedValues": {
"masterUserPassword": "****"
},
"performanceInsightsEnabled": false,
"preferredBackupWindow": "11:52-12:22",
"preferredMaintenanceWindow": "tue:13:03-tue:13:33",
"publiclyAccessible": true,
"readReplicaDBInstanceIdentifiers": [],
"storageEncrypted": true,
"storageThroughput": 0,
"storageType": "gp2",
"tagList": [],
"vpcSecurityGroups": [
{
"status": "active",
"vpcSecurityGroupId": "sg-d963a5a4"
}
]
},
"sessionCredentialFromConsole": true,
"sourceIPAddress": "AWS Internal",
"userAgent": "AWS Internal",
"userIdentity": {
"accessKeyId": "ASIASWJRT64ZWCAMGCWI",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/Admin/Jack",
"principalId": "AROAJ4ULUNLE6DYF4PCOK:jack",
"sessionContext": {
"attributes": {
"creationDate": "2022-09-23T23:17:13Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/Admin",
"principalId": "AROAJ4ULUNLE6DYF4PCOK",
"type": "Role",
"userName": "Admin"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS RDS Snapshot Copied Cross-Region
#Detects when RDS snapshots are copied to different AWS regions. While legitimate for disaster recovery, cross-region snapshot copies can be used for data exfiltration or to prepare snapshots for sharing with external accounts.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
copy_events = ["CopyDBSnapshot", "CopyDBClusterSnapshot"]
if event.get("eventName") not in copy_events:
return False
if event.deep_get("errorCode") is not None:
return False
source_region = event.deep_get("requestParameters", "sourceRegion", default="")
target_region = event.get("awsRegion", "")
if source_region and target_region and source_region != target_region:
return True
return False
def title(event):
event_name = event.get("eventName", "Unknown")
source_id = event.deep_get("requestParameters", "sourceDBSnapshotIdentifier") or event.deep_get(
"requestParameters", "sourceDBClusterSnapshotIdentifier", default="<UNKNOWN>"
)
source_region = event.deep_get("requestParameters", "sourceRegion", default="<UNKNOWN>")
target_region = event.get("awsRegion", "<UNKNOWN>")
resource_type = "Snapshot" if event_name == "CopyDBSnapshot" else "Cluster Snapshot"
return (
f"RDS {resource_type} Copied Cross-Region: [{source_id}] "
f"from [{source_region}] to [{target_region}]"
)
def dedup(event):
target_id = event.deep_get("requestParameters", "targetDBSnapshotIdentifier") or event.deep_get(
"requestParameters", "targetDBClusterSnapshotIdentifier", default="unknown"
)
account_id = event.deep_get("recipientAccountId", default="unknown")
region = event.get("awsRegion", "unknown")
return f"{account_id}:{region}:{target_id}"
def alert_context(event):
context = aws_rule_context(event)
context["source_snapshot_identifier"] = event.deep_get(
"requestParameters", "sourceDBSnapshotIdentifier"
) or event.deep_get("requestParameters", "sourceDBClusterSnapshotIdentifier", default="N/A")
context["target_snapshot_identifier"] = event.deep_get(
"requestParameters", "targetDBSnapshotIdentifier"
) or event.deep_get("requestParameters", "targetDBClusterSnapshotIdentifier", default="N/A")
context["source_region"] = event.deep_get("requestParameters", "sourceRegion", default="N/A")
context["kms_key_id"] = event.deep_get("requestParameters", "kmsKeyId", default="N/A")
context["copy_tags"] = event.deep_get("requestParameters", "copyTags", default="N/A")
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_snapshot_copied_cross_region.py
RuleID: "AWS.RDS.SnapshotCopiedCrossRegion"
DisplayName: "AWS RDS Snapshot Copied Cross-Region"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Exfiltration
- Transfer Data to Cloud Account
- RDS
Severity: Medium
Description: >
Detects when RDS snapshots are copied to different AWS regions. While legitimate for
disaster recovery, cross-region snapshot copies can be used for data exfiltration or
to prepare snapshots for sharing with external accounts.
Runbook: |
1. Find all snapshot copy operations by the user ARN in the past 24 hours to identify bulk copying patterns
2. Check if cross-region snapshot copies are normal for this user by searching the past 90 days
3. Look for snapshot sharing events in the target region in the 48 hours after this copy
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_CopySnapshot.html
Reports:
MITRE ATT&CK:
- TA0010:T1537 # Transfer Data to Cloud Account
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:sourceRegion
- awsRegion
- requestParameters:sourceDBSnapshotIdentifier
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameis one ofCopyDBSnapshot,CopyDBClusterSnapshoterrorCodeis emptyrequestParameters.sourceRegionis presentawsRegionis presentrequestParameters.sourceRegiondiffers from fieldawsRegion
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
awsRegion | is_not_null | field:"awsRegion" kind:is_not_null | |
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
requestParameters.sourceRegion | cross_field_compare |
| field:"requestParameters.sourceRegion" kind:cross_field_compare value:"awsRegion" |
requestParameters.sourceRegion | is_not_null | field:"requestParameters.sourceRegion" kind:is_not_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
sourceDBSnapshotIdentifier | requestParameters.sourceDBSnapshotIdentifier |
sourceRegion | requestParameters.sourceRegion |
Response runbook
1. Find all snapshot copy operations by the user ARN in the past 24 hours to identify bulk copying patterns
2. Check if cross-region snapshot copies are normal for this user by searching the past 90 days
3. Look for snapshot sharing events in the target region in the 48 hours after this copy
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "CopyDBSnapshot",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-17T11:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"copyTags": true,
"kmsKeyId": "arn:aws:kms:us-west-2:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab",
"sourceDBSnapshotIdentifier": "arn:aws:rds:us-east-1:123456789012:snapshot:prod-backup-2024",
"sourceRegion": "us-east-1",
"targetDBSnapshotIdentifier": "prod-backup-2024-copy"
},
"responseElements": {
"dBSnapshotArn": "arn:aws:rds:us-west-2:123456789012:snapshot:prod-backup-2024-copy",
"dBSnapshotIdentifier": "prod-backup-2024-copy",
"sourceDBSnapshotIdentifier": "arn:aws:rds:us-east-1:123456789012:snapshot:prod-backup-2024",
"sourceRegion": "us-east-1",
"status": "copying"
},
"sourceIPAddress": "203.0.113.45",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/DeveloperRole/user",
"principalId": "AIDAI23HXS3EXAMPLE:user",
"type": "AssumedRole"
}
}
AWS RDS Snapshot Deleted
#Detects deletion of RDS snapshots. Attackers delete backups to prevent recovery or hide evidence of data exfiltration. Multiple snapshot deletions may indicate ransomware preparing to encrypt databases without recovery options.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Stealth | |
| Impact |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS RDS Snapshot Deleted (Elastic)
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
deletion_events = ["DeleteDBSnapshot", "DeleteDBClusterSnapshot"]
if event.get("eventName") not in deletion_events:
return False
return event.deep_get("errorCode") is None
def title(event):
event_name = event.get("eventName", "Unknown")
snapshot_id = event.deep_get("requestParameters", "dBSnapshotIdentifier") or event.deep_get(
"requestParameters", "dBClusterSnapshotIdentifier", default="<UNKNOWN>"
)
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
resource_type = "Snapshot" if event_name == "DeleteDBSnapshot" else "Cluster Snapshot"
return f"RDS {resource_type} Deleted: [{snapshot_id}] by [{user}]"
def dedup(event):
snapshot_id = event.deep_get("requestParameters", "dBSnapshotIdentifier") or event.deep_get(
"requestParameters", "dBClusterSnapshotIdentifier", default="unknown"
)
account_id = event.deep_get("recipientAccountId", default="unknown")
region = event.get("awsRegion", "unknown")
return f"{account_id}:{region}:{snapshot_id}"
def alert_context(event):
context = aws_rule_context(event)
context["snapshot_identifier"] = event.deep_get(
"requestParameters", "dBSnapshotIdentifier"
) or event.deep_get("requestParameters", "dBClusterSnapshotIdentifier", default="N/A")
context["snapshot_arn"] = event.deep_get("responseElements", "dBSnapshotArn") or event.deep_get(
"responseElements", "dBClusterSnapshotArn", default="N/A"
)
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_snapshot_deleted.py
RuleID: "AWS.RDS.SnapshotDeleted"
DisplayName: "AWS RDS Snapshot Deleted"
Enabled: true
Status: Experimental
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Defense Evasion
- Impact
- Data Destruction
- Indicator Removal
- RDS
Severity: High
Description: >
Detects deletion of RDS snapshots. Attackers delete backups to prevent recovery or hide
evidence of data exfiltration. Multiple snapshot deletions may indicate ransomware preparing
to encrypt databases without recovery options.
Runbook: |
1. Find all snapshot deletion events by the user ARN in the past 24 hours to identify bulk deletion patterns
2. Check if the deleted snapshot was shared with external accounts in the 7 days before deletion
3. Look for database deletion or modification events from this user in the 2 hours after this snapshot deletion
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_WorkingWithAutomatedBackups.html
Reports:
MITRE ATT&CK:
- TA0040:T1485 # Data Destruction
- TA0005:T1070 # Indicator Removal
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:dBSnapshotIdentifier
- requestParameters:dBClusterSnapshotIdentifier
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameis one ofDeleteDBSnapshot,DeleteDBClusterSnapshoterrorCodeis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
dBSnapshotIdentifier | requestParameters.dBSnapshotIdentifier |
userName | userIdentity.userName |
Response runbook
1. Find all snapshot deletion events by the user ARN in the past 24 hours to identify bulk deletion patterns
2. Check if the deleted snapshot was shared with external accounts in the 7 days before deletion
3. Look for database deletion or modification events from this user in the 2 hours after this snapshot deletion
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "DeleteDBSnapshot",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-16T08:15:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"dBSnapshotIdentifier": "backup-2024-01-15"
},
"responseElements": {
"dBSnapshotArn": "arn:aws:rds:us-west-2:123456789012:snapshot:backup-2024-01-15",
"dBSnapshotIdentifier": "backup-2024-01-15",
"snapshotType": "manual",
"status": "deleted"
},
"sourceIPAddress": "198.51.100.25",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/PowerUserRole/user",
"principalId": "AIDAI23HXS3EXAMPLE:user",
"type": "AssumedRole"
}
}
AWS RDS Snapshot Enumeration with Public or Shared Flag
#Detects when RDS snapshots are queried with includePublic or includeShared flags. This indicates reconnaissance for publicly accessible or shared snapshots that may contain sensitive data and be exploitable by attackers.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
describe_events = ["DescribeDBSnapshots", "DescribeDBClusterSnapshots"]
if event.get("eventName") not in describe_events:
return False
if event.deep_get("errorCode") is not None:
return False
include_public = event.deep_get("requestParameters", "includePublic", default=False)
include_shared = event.deep_get("requestParameters", "includeShared", default=False)
if include_public or include_shared:
return True
return False
def title(event):
event_name = event.get("eventName", "Unknown")
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
include_public = event.deep_get("requestParameters", "includePublic", default=False)
include_shared = event.deep_get("requestParameters", "includeShared", default=False)
if include_public and include_shared:
scope = "Public and Shared"
elif include_public:
scope = "Public"
elif include_shared:
scope = "Shared"
else:
scope = "Unknown"
resource_type = "Snapshots" if event_name == "DescribeDBSnapshots" else "Cluster Snapshots"
return f"RDS {resource_type} Enumeration: {scope} snapshots queried by [{user}]"
def dedup(event):
user_arn = event.deep_get("userIdentity", "arn", default="unknown")
account_id = event.deep_get("recipientAccountId", default="unknown")
region = event.get("awsRegion", "unknown")
return f"{account_id}:{region}:{user_arn}"
def alert_context(event):
context = aws_rule_context(event)
context["include_public"] = event.deep_get("requestParameters", "includePublic", default="N/A")
context["include_shared"] = event.deep_get("requestParameters", "includeShared", default="N/A")
context["max_records"] = event.deep_get("requestParameters", "maxRecords", default="N/A")
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_snapshot_enumeration.py
RuleID: "AWS.RDS.SnapshotEnumeration"
DisplayName: "AWS RDS Snapshot Enumeration with Public or Shared Flag"
Enabled: true
Status: Experimental
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Discovery
- Cloud Infrastructure Discovery
- Reconnaissance
- RDS
Severity: Low
Description: >
Detects when RDS snapshots are queried with includePublic or includeShared flags.
This indicates reconnaissance for publicly accessible or shared snapshots that may
contain sensitive data and be exploitable by attackers.
Runbook: |
1. Find all snapshot enumeration events by the user ARN in the past 6 hours to identify systematic patterns
2. Check if the source IP address is associated with known VPN or proxy services
3. Look for snapshot copy or restore operations from this user in the 24 hours after the enumeration
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ListSnapshots.html
Reports:
MITRE ATT&CK:
- TA0007:T1580 # Cloud Infrastructure Discovery
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:includePublic
- requestParameters:includeShared
- sourceIPAddress
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameis one ofDescribeDBSnapshots,DescribeDBClusterSnapshotserrorCodeis emptyany of:
requestParameters.includePublicis presentrequestParameters.includeSharedis present
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
requestParameters.includePublic | is_not_null | field:"requestParameters.includePublic" kind:is_not_null | |
requestParameters.includeShared | is_not_null | field:"requestParameters.includeShared" kind:is_not_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
userName | userIdentity.userName |
Response runbook
1. Find all snapshot enumeration events by the user ARN in the past 6 hours to identify systematic patterns
2. Check if the source IP address is associated with known VPN or proxy services
3. Look for snapshot copy or restore operations from this user in the 24 hours after the enumeration
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "DescribeDBSnapshots",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-18T05:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": true,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"includePublic": true,
"maxRecords": 100
},
"responseElements": null,
"sourceIPAddress": "185.220.101.75",
"userAgent": "Boto3/1.26.0 Python/3.9.0",
"userIdentity": {
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/researcher",
"principalId": "AIDAI23HXS3EXAMPLE",
"type": "IAMUser",
"userName": "researcher"
}
}
AWS RDS Snapshot Exported to S3
#Detects when an RDS snapshot is exported to S3 using StartExportTask. Attackers use this to exfiltrate database contents by exporting snapshots to buckets they control. While snapshot exports are legitimate for analytics, they provide complete database access.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS RDS Snapshot Export (Elastic)
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return all(
[
event.get("eventSource") == "rds.amazonaws.com",
event.get("eventName") == "StartExportTask",
event.deep_get("errorCode") is None,
]
)
def title(event):
db_identifier = (
event.deep_get("requestParameters", "sourceArn", default="<UNKNOWN_SOURCE>")
.split(":")[-1]
.split("/")[-1]
)
s3_bucket = event.deep_get("requestParameters", "s3BucketName", default="<UNKNOWN_BUCKET>")
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
return f"RDS Snapshot Export: [{db_identifier}] exported to S3 bucket [{s3_bucket}] by [{user}]"
def dedup(event):
db_identifier = (
event.deep_get("requestParameters", "sourceArn", default="<UNKNOWN_SOURCE>")
.split(":")[-1]
.split("/")[-1]
)
account_id = event.deep_get("recipientAccountId", default="unknown")
region = event.get("awsRegion", "unknown")
return f"{account_id}:{region}:{db_identifier}"
def alert_context(event):
context = aws_rule_context(event)
context["export_task_id"] = event.deep_get(
"responseElements", "exportTaskIdentifier", default="N/A"
)
context["source_arn"] = event.deep_get("requestParameters", "sourceArn", default="N/A")
context["s3_bucket"] = event.deep_get("requestParameters", "s3BucketName", default="N/A")
context["s3_prefix"] = event.deep_get("requestParameters", "s3Prefix", default="N/A")
context["kms_key_id"] = event.deep_get("requestParameters", "kmsKeyId", default="N/A")
context["iam_role_arn"] = event.deep_get("requestParameters", "iamRoleArn", default="N/A")
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_snapshot_export.py
RuleID: "AWS.RDS.SnapshotExport"
DisplayName: "AWS RDS Snapshot Exported to S3"
Enabled: true
Status: Experimental
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Exfiltration
- Transfer Data to Cloud Account
- RDS
Severity: High
Description: >
Detects when an RDS snapshot is exported to S3 using StartExportTask. Attackers use this
to exfiltrate database contents by exporting snapshots to buckets they control. While
snapshot exports are legitimate for analytics, they provide complete database access.
Runbook: |
1. Find all API calls by the user ARN in the 24 hours before the alert to establish normal behavior
2. Check if the S3 bucket in requestParameters:s3BucketName has been accessed by this user in the past 90 days
3. Look for other StartExportTask events or snapshot operations from this user in the past 7 days
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ExportSnapshot.html
Reports:
MITRE ATT&CK:
- TA0010:T1537 # Exfiltration: Transfer Data to Cloud Account
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:s3BucketName
- requestParameters:sourceArn
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameisStartExportTaskerrorCodeis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | eq |
| field:"aws::eventName" kind:eq value:"StartExportTask" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
s3BucketName | requestParameters.s3BucketName |
userName | userIdentity.userName |
Response runbook
1. Find all API calls by the user ARN in the 24 hours before the alert to establish normal behavior
2. Check if the S3 bucket in requestParameters:s3BucketName has been accessed by this user in the past 90 days
3. Look for other StartExportTask events or snapshot operations from this user in the past 7 days
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "StartExportTask",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-15T10:30:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"exportOnly": [
"schema1.table1",
"schema1.table2"
],
"iamRoleArn": "arn:aws:iam::123456789012:role/RDSExportRole",
"kmsKeyId": "arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab",
"s3BucketName": "my-export-bucket",
"s3Prefix": "exports/2024-01-15/",
"sourceArn": "arn:aws:rds:us-east-1:123456789012:snapshot:my-db-snapshot"
},
"responseElements": {
"exportTaskIdentifier": "export-snapshot-2024-01-15-103000",
"iamRoleArn": "arn:aws:iam::123456789012:role/RDSExportRole",
"kmsKeyId": "arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab",
"percentProgress": 0,
"s3Bucket": "my-export-bucket",
"s3Prefix": "exports/2024-01-15/",
"snapshotTime": "2024-01-14T00:00:00Z",
"sourceArn": "arn:aws:rds:us-east-1:123456789012:snapshot:my-db-snapshot",
"status": "STARTING",
"taskStartTime": "2024-01-15T10:30:00Z",
"totalExtractedDataInGB": 0
},
"sourceIPAddress": "1.2.3.4",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/Admin/user",
"principalId": "AIDAI23HXS3EXAMPLE:user",
"type": "AssumedRole"
}
}
AWS Redshift Cluster Encryption
#This policy validates that Redshift Clusters have encryption enabled.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
def policy(resource):
return bool(resource["Encrypted"])
Rule specification
AnalysisType: policy
Filename: aws_redshift_cluster_encryption.py
PolicyID: "AWS.Redshift.Cluster.Encryption"
DisplayName: "AWS Redshift Cluster Encryption"
Enabled: true
ResourceTypes:
- AWS.Redshift.Cluster
Tags:
- AWS
- Data Protection
- Collection:Data From Cloud Storage Object
Reports:
PCI:
- 3.4
MITRE ATT&CK:
- TA0009:T1530
Severity: High
Description: >
This policy validates that Redshift Clusters have encryption enabled.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-redshift-cluster-has-encryption-enabled
Reference: https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-db-encryption.html
Stages and Predicates
Flags AWS.Redshift.Cluster resources when the condition below holds.
Condition
Encryptedis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Encrypted | is_not_null | excludes:Encrypted |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Encrypted | is_null | field:"Encrypted" kind:is_null |
Response runbook
AWS Redshift Cluster Has Acceptable Snapshot Retention Period
#This policy validates that Redshift Cluster snapshot retention periods are set to an appropriate time. This ensures that records are kept long enough for compliance and security reasons, but no too long for compliance and performance reasons.
Detection logic
# Retention period in days
MIN_RETENTION_DAYS = 3
MAX_RETENTION_DAYS = 90
def policy(resource):
return MIN_RETENTION_DAYS <= resource["AutomatedSnapshotRetentionPeriod"] <= MAX_RETENTION_DAYS
Rule specification
AnalysisType: policy
Filename: aws_redshift_cluster_snapshot_retention_acceptable.py
PolicyID: "AWS.Redshift.Cluster.SnapshotRetentionAcceptable"
DisplayName: "AWS Redshift Cluster Has Acceptable Snapshot Retention Period"
Enabled: false
ResourceTypes:
- AWS.Redshift.Cluster
Tags:
- AWS
- PCI
- Database
Reports:
PCI:
- 3.1
Severity: Low
Description: >
This policy validates that Redshift Cluster snapshot retention periods are set to an appropriate time. This ensures that records are kept long enough for compliance and security reasons, but no too long for compliance and performance reasons.
Runbook: >
Adjust the Cluster snapshot retention period to be an appropriate length.
Reference: https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-snapshots.html
Stages and Predicates
Flags AWS.Redshift.Cluster resources when any of the conditions below holds.
Condition
any of:
AutomatedSnapshotRetentionPeriodis less than3AutomatedSnapshotRetentionPeriodis greater than90
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
AutomatedSnapshotRetentionPeriod | ge | 3 | excludes:AutomatedSnapshotRetentionPeriod field:"AutomatedSnapshotRetentionPeriod" value:"3" |
AutomatedSnapshotRetentionPeriod | le | 90 | excludes:AutomatedSnapshotRetentionPeriod field:"AutomatedSnapshotRetentionPeriod" value:"90" |
Indicators
These rows show field, operator, and value matches.
Response runbook
Adjust the Cluster snapshot retention period to be an appropriate length.
AWS Redshift Cluster Logging
#This policy validates that Redshift Cluster have logging enabled. This includes audit logs.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
return deep_get(resource, "LoggingStatus", "LoggingEnabled", default=False)
Rule specification
AnalysisType: policy
Filename: aws_redshift_cluster_logging.py
PolicyID: "AWS.Redshift.Cluster.Logging"
DisplayName: "AWS Redshift Cluster Logging"
Enabled: true
ResourceTypes:
- AWS.Redshift.Cluster
Reports:
MITRE ATT&CK:
- TA0005:T1562
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
Severity: Medium
Description: >
This policy validates that Redshift Cluster have logging enabled. This includes audit logs.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-redshift-cluster-has-logging-enabled
Reference: https://aws.amazon.com/premiumsupport/knowledge-center/logs-redshift-database-cluster/
Stages and Predicates
Flags AWS.Redshift.Cluster resources when the condition below holds.
Condition
LoggingStatus.LoggingEnabledis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
LoggingStatus.LoggingEnabled | is_not_null | excludes:LoggingStatus.LoggingEnabled |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
LoggingStatus.LoggingEnabled | is_null | field:"LoggingStatus.LoggingEnabled" kind:is_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-redshift-cluster-has-logging-enabled
AWS Redshift Cluster Maintenance Window
#This policy validates that Redshift Clusters have the correct preferred maintenance window configured.
Detection logic
MAINTENANCE_WINDOW = "sat:10:30-sat:11:00"
def policy(resource):
return resource["PreferredMaintenanceWindow"] == MAINTENANCE_WINDOW
Rule specification
AnalysisType: policy
Filename: aws_redshift_cluster_maintenance_window.py
PolicyID: "AWS.Redshift.Cluster.MaintenanceWindow"
DisplayName: "AWS Redshift Cluster Maintenance Window"
Enabled: false
ResourceTypes:
- AWS.Redshift.Cluster
Tags:
- AWS
- Configuration Required
Severity: Info
Description: >
This policy validates that Redshift Clusters have the correct preferred maintenance window configured.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-redshift-cluster-has-correct-preferred-maintenance-window
Reference: https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-clusters.html#rs-maintenance-windows
Stages and Predicates
Flags AWS.Redshift.Cluster resources when the condition below holds.
Condition
PreferredMaintenanceWindowis notsat:10:30-sat:11:00
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
PreferredMaintenanceWindow | eq | sat:10:30-sat:11:00 | excludes:PreferredMaintenanceWindow field:"PreferredMaintenanceWindow" value:"sat:10:30-sat:11:00" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
PreferredMaintenanceWindow | ne |
| field:"PreferredMaintenanceWindow" kind:ne value:"sat:10:30-sat:11:00" |
Response runbook
AWS Redshift Cluster Snapshot Retention
#This policy validates that Redshift Clusters have sufficient snapshot retention periods, so that snapshots are not lost before they are needed.
Detection logic
# Retention period in days
RETENTION_PERIOD = 3
def policy(resource):
return resource["AutomatedSnapshotRetentionPeriod"] >= RETENTION_PERIOD
Rule specification
AnalysisType: policy
Filename: aws_redshift_cluster_snapshot_retention.py
PolicyID: "AWS.Redshift.Cluster.SnapshotRetention"
DisplayName: "AWS Redshift Cluster Snapshot Retention"
Enabled: true
ResourceTypes:
- AWS.Redshift.Cluster
Tags:
- AWS
- Availability
Severity: Medium
Description: >
This policy validates that Redshift Clusters have sufficient snapshot retention periods, so that snapshots are not lost before they are needed.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-redshift-cluster-has-sufficient-snapshot-retention-period
Reference: https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-snapshots.html
Stages and Predicates
Flags AWS.Redshift.Cluster resources when the condition below holds.
Condition
AutomatedSnapshotRetentionPeriodis less than3
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
AutomatedSnapshotRetentionPeriod | ge | 3 | excludes:AutomatedSnapshotRetentionPeriod field:"AutomatedSnapshotRetentionPeriod" value:"3" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
AutomatedSnapshotRetentionPeriod | lt |
| field:"AutomatedSnapshotRetentionPeriod" kind:lt value:"3" |
Response runbook
AWS Redshift Cluster Version Upgrade
#This policy validates that Redshift Clusters automatically perform upgrades during scheduled maintenance windows.
Detection logic
def policy(resource):
# Explicit True check to avoid returning NoneType
return resource["AllowVersionUpgrade"] is True
Rule specification
AnalysisType: policy
Filename: aws_redshift_cluster_version_upgrade.py
PolicyID: "AWS.Redshift.Cluster.VersionUpgrade"
DisplayName: "AWS Redshift Cluster Version Upgrade"
Enabled: true
ResourceTypes:
- AWS.Redshift.Cluster
Tags:
- AWS
- Security Control
Reports:
PCI:
- 6.2
Severity: Low
Description: >
This policy validates that Redshift Clusters automatically perform upgrades during scheduled maintenance windows.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-redshift-cluster-allows-version-upgrades
Reference: https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-clusters.html
Stages and Predicates
Flags AWS.Redshift.Cluster resources when the condition below holds.
Condition
AllowVersionUpgradeis nottrue
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
AllowVersionUpgrade | eq | true | excludes:AllowVersionUpgrade field:"AllowVersionUpgrade" value:"true" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
AllowVersionUpgrade | ne |
| field:"AllowVersionUpgrade" kind:ne value:"true" |
Response runbook
AWS Resource Made Public
#Some AWS resource was made publicly accessible over the internet. Checks ECR, Elasticsearch, KMS, S3, S3 Glacier, SNS, SQS, and Secrets Manager.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Telemetry coverage
Detection logic
import json
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_base_helpers import deep_get
from policyuniverse.policy import Policy
def _has_organization_condition(statement):
"""
Check if a policy statement has organization ID conditions that restrict access.
Args:
statement: A policyuniverse Statement object
Returns:
bool: True if organization conditions are found, False otherwise
"""
# Check both the policyuniverse category and specific AWS condition keys
for condition in statement.condition_entries:
# Check policyuniverse category
if condition.category == "organization":
return True
# Also check for specific AWS organization condition keys
# These include aws:PrincipalOrgID, aws:SourceOrgID, aws:PrincipalOrgPaths, etc.
condition_key = getattr(condition, "key", "").lower()
if "orgid" in condition_key or "orgpath" in condition_key:
return True
# Alternative: Check raw conditions in the statement if condition_entries doesn't work
if hasattr(statement, "statement"):
raw_conditions = statement.statement.get("Condition", {})
for conditions in raw_conditions.values():
for key in conditions.keys():
# Check for organization-related condition keys
if any(
org_key in key.lower()
for org_key in ["principalorgid", "sourceorgid", "principalorgpaths"]
):
return True
return False
# Check if a policy (string or JSON) allows resource accessibility via the Internet
def policy_is_internet_accessible(policy):
"""
Check if a policy (string or JSON) allows resource accessibility via the Internet.
Args:
policy: A policy object that can be either a string or a JSON object
Returns:
bool: True if the policy allows internet access, False otherwise
"""
# Handle empty policies (None, empty strings, empty dicts, etc.)
if not policy:
return False
# Handle string policies by converting to JSON
if isinstance(policy, str):
try:
policy = json.loads(policy)
except json.JSONDecodeError:
return False
# Check if the policy has a wildcard principal but also has organization ID restrictions
# which should not be considered internet accessible
policy_obj = Policy(policy)
# If policyuniverse thinks it's not internet accessible, trust that
if not policy_obj.is_internet_accessible():
return False
# For policies with multiple statements, we need to check each statement individually
# If ANY statement is truly internet accessible, the policy is internet accessible
for statement in policy_obj.statements:
if statement.effect != "Allow" or "*" not in statement.principals:
continue
# If this statement has a wildcard principal but no organization ID restrictions,
# it's truly internet accessible
if not _has_organization_condition(statement):
return True
return False
def rule(event):
if not aws_cloudtrail_success(event):
return False
parameters = event.get("requestParameters", {})
# Ignore events that are missing request params
if not parameters:
return False
event_name = event.get("eventName", "")
# Special case for SNS topic attributes that need additional attribute name check
if event_name == "SetTopicAttributes" and parameters.get("attributeName", "") == "Policy":
policy_value = parameters.get("attributeValue", {})
return policy_is_internet_accessible(policy_value)
# Map of event names to policy locations in parameters
policy_location_map = {
# S3
"PutBucketPolicy": lambda p: p.get("bucketPolicy", {}),
# ECR
"SetRepositoryPolicy": lambda p: p.get("policyText", {}),
# Elasticsearch
"CreateElasticsearchDomain": lambda p: p.get("accessPolicies", {}),
"UpdateElasticsearchDomainConfig": lambda p: p.get("accessPolicies", {}),
# KMS
"CreateKey": lambda p: p.get("policy", {}),
"PutKeyPolicy": lambda p: p.get("policy", {}),
# S3 Glacier
"SetVaultAccessPolicy": lambda p: deep_get(p, "policy", "policy", default={}),
# SNS & SQS
"SetQueueAttributes": lambda p: deep_get(p, "attributes", "Policy", default={}),
"CreateTopic": lambda p: deep_get(p, "attributes", "Policy", default={}),
# SecretsManager
"PutResourcePolicy": lambda p: p.get("resourcePolicy", {}),
}
# Get the policy extraction function for this event name
policy_extractor = policy_location_map.get(event_name)
if not policy_extractor:
return False
# Extract the policy using the appropriate function
policy = policy_extractor(parameters)
return policy_is_internet_accessible(policy)
def title(event):
# TODO(): Update this rule to use data models
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity",
"sessionContext",
"sessionIssuer",
"userName",
default="<MISSING_USER>",
)
if event.get("Resources"):
return f"Resource {event.get('Resources')[0].get('arn', 'MISSING')} made public by {user}"
return f"{event.get('eventSource', 'MISSING SOURCE')} resource made public by {user}"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_resource_made_public.py
RuleID: "AWS.CloudTrail.ResourceMadePublic"
DisplayName: "AWS Resource Made Public"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Exfiltration:Transfer Data to Cloud Account
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0010:T1537
Description: >
Some AWS resource was made publicly accessible over the internet.
Checks ECR, Elasticsearch, KMS, S3, S3 Glacier, SNS, SQS, and Secrets Manager.
Runbook: Adjust the policy so that the resource is no longer publicly accessible
Reference: https://aws.amazon.com/blogs/security/identifying-publicly-accessible-resources-with-amazon-vpc-network-access-analyzer/
SummaryAttributes:
- userAgent
- sourceIpAddress
- vpcEndpointId
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyrequestParametersis presentany of:
all of:
eventNameisSetTopicAttributesrequestParameters.attributeNameisPolicyrequestParameters.attributeValueis present
eventNameis notSetTopicAttributesrequestParameters.attributeNameis notPolicy
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
Resources | |
userName | userIdentity.userName |
Response runbook
Adjust the policy so that the resource is no longer publicly accessible
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "eu-west-1",
"eventID": "685e066d-a3aa-4323-a6a1-2f187a2fc986",
"eventName": "SetRepositoryPolicy",
"eventSource": "ecr.amazonaws.com",
"eventTime": "2020-11-20 06:19:05.000",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"p_any_aws_account_ids": [
"112233445566"
],
"p_any_aws_arns": [
"arn:aws:ecr:eu-west-1:112233445566:repository/community",
"arn:aws:iam::112233445566:role/ServiceRole",
"arn:aws:sts::112233445566:assumed-role/ServiceRole/AWSCloudFormation"
],
"p_event_time": "2020-11-20 06:19:05.000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2020-11-20 06:31:53.258",
"p_row_id": "ea68a92f0295a6bed49fa8af068faa05",
"recipientAccountId": "112233445566",
"requestID": "95fd6392-627c-467b-b940-895183d3298d",
"requestParameters": {
"force": false,
"policyText": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":[\"ecr:BatchCheckLayerAvailability\",\"ecr:BatchGetImage\",\"ecr:GetAuthorizationToken\",\"ecr:GetDownloadUrlForLayer\"],\"Effect\":\"Allow\",\"Principal\":\"*\",\"Sid\":\"PublicRead\"}]}",
"repositoryName": "community"
},
"resources": [
{
"accountId": "112233445566",
"arn": "arn:aws:ecr:eu-west-1:112233445566:repository/community"
}
],
"responseElements": {
"policyText": "{\n \"Version\" : \"2012-10-17\",\n \"Statement\" : [ {\n \"Sid\" : \"PublicRead\",\n \"Effect\" : \"Allow\",\n \"Principal\" : \"*\",\n \"Action\" : [ \"ecr:BatchCheckLayerAvailability\", \"ecr:BatchGetImage\", \"ecr:GetAuthorizationToken\", \"ecr:GetDownloadUrlForLayer\" ]\n } ]\n}",
"registryId": "112233445566",
"repositoryName": "community"
},
"sourceIPAddress": "cloudformation.amazonaws.com",
"userAgent": "cloudformation.amazonaws.com",
"userIdentity": {
"accessKeyId": "ASIAIJJG73VC6IW5OFVQ",
"accountId": "112233445566",
"arn": "arn:aws:sts::112233445566:assumed-role/ServiceRole/AWSCloudFormation",
"invokedBy": "cloudformation.amazonaws.com",
"principalId": "AROAJJJJTTTT44445IJJJ:AWSCloudFormation",
"sessionContext": {
"attributes": {
"creationDate": "2020-11-20T06:19:04Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "112233445566",
"arn": "arn:aws:iam::112233445566:role/ServiceRole",
"principalId": "AROAJJJJTTTT44445IJJJ",
"type": "Role",
"userName": "ServiceRole"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Root Account Access Keys
#Validates that no programmatic access keys exist for the AWS root account. Root access keys provide unrestricted access to all AWS resources and cannot have permissions limited. If compromised, these keys grant attackers complete account control including resource modification, data access, and billing changes.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
return not deep_get(resource, "CredentialReport", "AccessKey1Active") and not deep_get(
resource, "CredentialReport", "AccessKey2Active"
)
Rule specification
AnalysisType: policy
Filename: aws_root_account_access_keys.py
PolicyID: "AWS.RootAccount.AccessKeys"
DisplayName: "AWS Root Account Access Keys"
Enabled: true
ResourceTypes:
- AWS.IAM.RootUser
Tags:
- AWS
- Identity & Access Management
- Persistence:Account Manipulation
Reports:
CIS:
- 1.12
PCI:
- 2.2.2
- 7.1
- 8.2
MITRE ATT&CK:
- TA0003:T1098
Severity: Critical
Description: >
Validates that no programmatic access keys exist for the AWS root account. Root access keys provide unrestricted access to all AWS resources and cannot have permissions limited. If compromised, these keys grant attackers complete account control including resource modification, data access, and billing changes.
Runbook: |
1. Query CloudTrail for all API calls where userIdentity.type equals "Root" in the past 90 days to identify if the root access keys are actively being used and document which applications or automation depend on them
2. Create IAM users or roles with minimum required permissions, update all applications to use the new IAM credentials, then delete the root access keys via AWS Console Security Credentials page
3. Enable CloudWatch Events rules to alert on future root account activity and consider implementing AWS Organizations SCPs to prevent root access key creation
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html
Stages and Predicates
Flags AWS.IAM.RootUser resources when any of the conditions below holds.
Condition
any of:
CredentialReport.AccessKey1Activeis presentCredentialReport.AccessKey2Activeis present
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
CredentialReport.AccessKey1Active | is_null | excludes:CredentialReport.AccessKey1Active | |
CredentialReport.AccessKey2Active | is_null | excludes:CredentialReport.AccessKey2Active |
Indicators
These rows show field, operator, and value matches.
Response runbook
1. Query CloudTrail for all API calls where userIdentity.type equals "Root" in the past 90 days to identify if the root access keys are actively being used and document which applications or automation depend on them
2. Create IAM users or roles with minimum required permissions, update all applications to use the new IAM credentials, then delete the root access keys via AWS Console Security Credentials page
3. Enable CloudWatch Events rules to alert on future root account activity and consider implementing AWS Organizations SCPs to prevent root access key creation
AWS Root Account Hardware MFA
#This policy validates that a hardware MFA device is in use for access to the root account.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
if not deep_get(resource, "CredentialReport", "MfaActive"):
# MFA is not enabled, this is reported by a different rule
return True
return resource["VirtualMFA"] is None
Rule specification
AnalysisType: policy
Filename: aws_root_account_hardware_mfa.py
PolicyID: "AWS.RootAccount.HardwareMFA"
DisplayName: "AWS Root Account Hardware MFA"
Enabled: true
ResourceTypes:
- AWS.IAM.RootUser
Tags:
- AWS
- Identity & Access Management
- Privilege Escalation:Valid Accounts
Reports:
CIS:
- 1.13
- 1.14
PCI:
- 7.1.2
MITRE ATT&CK:
- TA0004:T1078
Severity: High
Description: >
This policy validates that a hardware MFA device is in use for access to the root account.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-root-account-has-mfa-enabled
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html
Stages and Predicates
Flags AWS.IAM.RootUser resources when all of the conditions below hold.
Condition
CredentialReport.MfaActiveis presentVirtualMFAis present
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
CredentialReport.MfaActive | is_null | excludes:CredentialReport.MfaActive | |
VirtualMFA | is_null | excludes:VirtualMFA |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
CredentialReport.MfaActive | is_not_null | field:"CredentialReport.MfaActive" kind:is_not_null | |
VirtualMFA | is_not_null | field:"VirtualMFA" kind:is_not_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-root-account-has-mfa-enabled
AWS Root Account MFA
#Validates that Multi-Factor Authentication (MFA) is enabled for the AWS root account. The root account has complete unrestricted access to all AWS resources and is the highest-value target for attackers. Without MFA, accounts are vulnerable to phishing, credential stuffing, and password compromise attacks.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
# Explicit check for True as the value may be None, and we want to return a bool not a NoneType
return deep_get(resource, "CredentialReport", "MfaActive") is True
Rule specification
AnalysisType: policy
Filename: aws_root_account_mfa.py
PolicyID: "AWS.RootAccount.MFA"
DisplayName: "AWS Root Account MFA"
Enabled: true
ResourceTypes:
- AWS.IAM.RootUser
Tags:
- AWS
- Identity & Access Management
- Privilege Escalation:Valid Accounts
Reports:
CIS:
- 1.13
PCI:
- 8.3
- 8.4
MITRE ATT&CK:
- TA0004:T1078
Severity: Critical
Description: >
Validates that Multi-Factor Authentication (MFA) is enabled for the AWS root account. The root account has complete unrestricted access to all AWS resources and is the highest-value target for attackers. Without MFA, accounts are vulnerable to phishing, credential stuffing, and password compromise attacks.
Runbook: |
1. Query CloudTrail for ConsoleLogin events where userIdentity.type equals "Root" in the past 90 days to identify when the root account was last used and assess the risk window without MFA
2. Sign in to AWS Console as root, navigate to Security Credentials, assign an MFA device (Virtual MFA, Hardware TOTP, or FIDO security key), and test the MFA configuration by logging out and back in
3. Store the root password and MFA device in separate secure locations, document the MFA device details, and implement AWS Organizations SCPs to monitor root account console logins
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html
Stages and Predicates
Flags AWS.IAM.RootUser resources when the condition below holds.
Condition
CredentialReport.MfaActiveis nottrue
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
CredentialReport.MfaActive | eq | true | excludes:CredentialReport.MfaActive field:"CredentialReport.MfaActive" value:"true" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
CredentialReport.MfaActive | ne |
| field:"CredentialReport.MfaActive" kind:ne value:"true" |
Response runbook
1. Query CloudTrail for ConsoleLogin events where userIdentity.type equals "Root" in the past 90 days to identify when the root account was last used and assess the risk window without MFA
2. Sign in to AWS Console as root, navigate to Security Credentials, assign an MFA device (Virtual MFA, Hardware TOTP, or FIDO security key), and test the MFA configuration by logging out and back in
3. Store the root password and MFA device in separate secure locations, document the MFA device details, and implement AWS Organizations SCPs to monitor root account console logins
AWS S3 Access Error
#Checks for errors during S3 Object access. This could be due to insufficient access permissions, non-existent buckets, or other reasons.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Detection logic
from panther_aws_helpers import aws_rule_context
from panther_base_helpers import pattern_match
# https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html
HTTP_STATUS_CODES_TO_MONITOR = {
403, # Forbidden
405, # Method Not Allowed
}
def rule(event):
if event.get("useragent", "").startswith("aws-internal"):
return False
return (
pattern_match(event.get("operation", ""), "REST.*.OBJECT")
and event.get("httpstatus") in HTTP_STATUS_CODES_TO_MONITOR
)
def title(event):
return f"{event.get('httpstatus')} errors found to S3 Bucket [{event.get('bucket')}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_s3_access_error.py
RuleID: "AWS.S3.ServerAccess.Error"
DisplayName: "AWS S3 Access Error"
DedupPeriodMinutes: 180
Threshold: 5
Enabled: true
LogTypes:
- AWS.S3ServerAccess
Tags:
- AWS
- Security Control
- Discovery:Cloud Storage Object Discovery
Reports:
MITRE ATT&CK:
- TA0007:T1619
Severity: Info
Description: >
Checks for errors during S3 Object access.
This could be due to insufficient access permissions, non-existent buckets, or other reasons.
Runbook: >
Investigate the specific error and determine if it is an ongoing issue that needs to be addressed or a one off or transient error that can be ignored.
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/ErrorCode.html
SummaryAttributes:
- bucket
- key
- requester
- remoteip
- operation
- errorCode
Stages and Predicates
Fires on AWS.S3ServerAccess events when all of the conditions below hold.
Condition
useragentdoes not start withaws-internaloperationmatches the patternREST.*.OBJECThttpstatusis one of403,405
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
useragent | starts_with | aws-internal | excludes:useragent field:"useragent" value:"aws-internal" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
httpstatus | in |
| field:"httpstatus" kind:in |
operation | wildcard |
| field:"operation" kind:wildcard value:"REST.*.OBJECT" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
httpstatus |
bucket |
Response runbook
Investigate the specific error and determine if it is an ongoing issue that needs to be addressed or a one off or transient error that can be ignored.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"bucket": "panther-auditlogs",
"errorcode": "AccessDenied",
"httpstatus": 403,
"operation": "REST.GET.OBJECT",
"remoteip": "10.106.38.245",
"requester": "arn:aws:iam::162777425019:user/awslogsdelivery",
"requestid": "5CDAB4038253B0E4",
"time": "2020-04-22 07:48:45.000",
"tlsversion": "TLSv1.2"
}
AWS S3 Access IP Allowlist
#Checks that the remote IP accessing the S3 bucket is in the IP allowlist.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
from ipaddress import IPv4Network, IPv6Network, ip_network
from panther_aws_helpers import aws_rule_context
BUCKETS_TO_MONITOR = {
# Example bucket names to watch go here
}
ALLOWLIST_NETWORKS = {
# IP addresses (in CIDR notation) indicating approved IP ranges for accessing S3 buckets}
ip_network("10.0.0.0/8"),
}
def rule(event):
if BUCKETS_TO_MONITOR:
if event.get("bucket") not in BUCKETS_TO_MONITOR:
return False
if "remoteip" not in event:
return False
cidr_ip = ip_network(event.get("remoteip"))
return not any(
is_subnet(approved_ip_range, cidr_ip) for approved_ip_range in ALLOWLIST_NETWORKS
)
def title(event):
return f"Non-Approved IP access to S3 Bucket [{event.get('bucket', '<UNKNOWN_BUCKET>')}]"
def alert_context(event):
return aws_rule_context(event)
def is_subnet(supernet: IPv4Network | IPv6Network, subnet: IPv4Network | IPv6Network) -> bool:
"""Return true if 'subnet' is a subnet of 'supernet'"""
# We can't do a classic subnet comparison between v4 and v6 networks, so we have to explictly
# check for version mismatch first
if supernet.network_address.version != subnet.network_address.version:
return False
# Else, do the subnet calculation
return subnet.subnet_of(supernet)
Rule specification
AnalysisType: rule
Filename: aws_s3_access_ip_allowlist.py
RuleID: "AWS.S3.ServerAccess.IPWhitelist"
DisplayName: "AWS S3 Access IP Allowlist"
DedupPeriodMinutes: 60 # 1 hour
Enabled: false
LogTypes:
- AWS.S3ServerAccess
Tags:
- AWS
- Configuration Required
- Identity & Access Management
- Collection:Data From Cloud Storage Object
Reports:
MITRE ATT&CK:
- TA0009:T1530
Severity: Medium
Description: >
Checks that the remote IP accessing the S3 bucket is in the IP allowlist.
Runbook: >
Verify whether unapproved access of S3 objects occurred, and take appropriate steps to remediate damage (for example, informing related parties of unapproved access and potentially invalidating data that was accessed). Consider updating the access policies of the S3 bucket to prevent future unapproved access.
Reference: https://aws.amazon.com/premiumsupport/knowledge-center/block-s3-traffic-vpc-ip/
SummaryAttributes:
- bucket
- key
- remoteip
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
bucket |
Response runbook
Verify whether unapproved access of S3 objects occurred, and take appropriate steps to remediate damage (for example, informing related parties of unapproved access and potentially invalidating data that was accessed). Consider updating the access policies of the S3 bucket to prevent future unapproved access.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"bucket": "my-test-bucket",
"remoteip": "11.0.0.1"
}
AWS S3 Bucket Action Restrictions
#Ensures that the S3 bucket policy does not allow any action on the bucket, in accordance with the principal of least privilege.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
import json
from policyuniverse.expander_minimizer import minimize_statement_actions
BAD_ACTIONS = {
"*",
"s3:*",
}
def policy(resource):
if resource["Policy"] is None:
return True
iam_policy = json.loads(resource["Policy"])
for statement in iam_policy["Statement"]:
# Only check statements granting access
if statement["Effect"] != "Allow":
continue
minimized_actions = minimize_statement_actions(statement)
if BAD_ACTIONS.intersection(minimized_actions):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_action_restrictions.py
PolicyID: "AWS.S3.Bucket.ActionRestrictions"
DisplayName: "AWS S3 Bucket Action Restrictions"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Identity & Access Management
- Impact:Data Destruction
Reports:
PCI:
- 10.5.1
- 10.5.2
MITRE ATT&CK:
- TA0040:T1485
Severity: Medium
Description: >
Ensures that the S3 bucket policy does not allow any action on the bucket, in accordance with the principal of least privilege.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-policy-restricts-allowed-actions
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/example-bucket-policies.html
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
Policyis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Policy | is_null | excludes:Policy |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Policy | is_not_null | field:"Policy" kind:is_not_null |
Response runbook
AWS S3 Bucket Encryption
#Ensures that the S3 bucket has encryption enabled.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
for encryption_rule in resource["EncryptionRules"] or []:
if encryption_rule.get("ApplyServerSideEncryptionByDefault", False):
return (
deep_get(encryption_rule, "ApplyServerSideEncryptionByDefault", "SSEAlgorithm")
is not None
)
return False
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_encryption.py
PolicyID: "AWS.S3.Bucket.Encryption"
DisplayName: "AWS S3 Bucket Encryption"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Data Protection
- Collection:Data From Cloud Storage Object
Reports:
PCI:
- 2.2.3
- 3.4
MITRE ATT&CK:
- TA0009:T1530
Severity: High
Description: >
Ensures that the S3 bucket has encryption enabled.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-encryption-enabled
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/bucket-encryption.html
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-encryption-enabled
AWS S3 Bucket Lifecycle Configuration
#Verifies that the S3 Bucket Object Lifecycle configuration expires data within 90 and 365 days.
Detection logic
from panther_base_helpers import deep_get
MAX_RETENTION_PERIOD = 365
MIN_RETENTION_PERIOD = 90
def policy(resource):
if resource.get("LifecycleRules") is None:
return False
for lifecycle_rule in resource.get("LifecycleRules", []):
if lifecycle_rule.get("Status") != "Enabled":
continue
rule_retention_period_days = deep_get(lifecycle_rule, "Expiration", "Days")
if not rule_retention_period_days:
continue
if MIN_RETENTION_PERIOD <= rule_retention_period_days <= MAX_RETENTION_PERIOD:
return True
return False
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_lifecycle_configuration.py
PolicyID: "AWS.S3.Bucket.LifecycleConfiguration"
DisplayName: "AWS S3 Bucket Lifecycle Configuration"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Configuration Required
- Security Control
Reports:
PCI:
- 3.1
- 10.7
Severity: Low
Description: >
Verifies that the S3 Bucket Object Lifecycle configuration expires data within 90 and 365 days.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-lifecycle-configuration-expires-data
Reference: https://amzn.to/2visoXr
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
LifecycleRulesis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
LifecycleRules | is_not_null | excludes:LifecycleRules |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
LifecycleRules | is_null | field:"LifecycleRules" kind:is_null |
Response runbook
AWS S3 Bucket Logging
#Ensures that a logging policy is set for the S3 bucket.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
def policy(resource):
return resource["LoggingPolicy"] is not None
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_logging.py
PolicyID: "AWS.S3.Bucket.Logging"
DisplayName: "AWS S3 Bucket Logging"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Monitoring
- Security Control
- Defense Evasion:Impair Defenses
Reports:
MITRE ATT&CK:
- TA0005:T1562
Severity: Low
Description: >
Ensures that a logging policy is set for the S3 bucket.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-logging-enabled
Reference: https://blog.runpanther.io/s3-bucket-access-logging/
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
LoggingPolicyis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
LoggingPolicy | is_not_null | excludes:LoggingPolicy |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
LoggingPolicy | is_null | field:"LoggingPolicy" kind:is_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-logging-enabled
AWS S3 Bucket MFA Delete
#Ensures that MFA delete is enabled for a bucket so that all objects can only be deleted by users authenticated with MFA.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
def policy(resource):
return resource["MFADelete"] == "Enabled"
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_mfa_delete.py
PolicyID: "AWS.S3.Bucket.MFADelete"
DisplayName: "AWS S3 Bucket MFA Delete"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Data Protection
- Impact:Data Destruction
Reports:
MITRE ATT&CK:
- TA0040:T1485
Severity: Low
Description: >
Ensures that MFA delete is enabled for a bucket so that all objects can only be deleted by users authenticated with MFA.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-has-mfa-delete-enabled
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
MFADeleteis notEnabled
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
MFADelete | eq | Enabled | excludes:MFADelete field:"MFADelete" value:"Enabled" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
MFADelete | ne |
| field:"MFADelete" kind:ne value:"Enabled" |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-has-mfa-delete-enabled
AWS S3 Bucket Name DNS Compliance
#This policy validates that the AWS S3 bucket name is DNS compliant.
Detection logic
def policy(resource):
return "." not in resource["Name"]
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_name_dns_compliance.py
PolicyID: "AWS.S3.Bucket.NameDNSCompliance"
DisplayName: "AWS S3 Bucket Name DNS Compliance"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
Severity: Info
Description: >
This policy validates that the AWS S3 bucket name is DNS compliant.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-name-has-no-periods
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
Namecontains.
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Name | contains |
| field:"Name" kind:contains value:"." |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-name-has-no-periods
AWS S3 Bucket Object Lock Configured
#This policy validates that S3 buckets have an Object Lock configuration enabled. This should be used with specific suppression lists to ensure it is applied only to appropriate S3 buckets, such as those containing CloudTrail or other auditable records.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
from panther_base_helpers import deep_get
RETENTION_PERIOD_DAYS = 365
def policy(resource):
object_lock = resource["ObjectLockConfiguration"]
# Object lock configuration is not enabled, or enabled without a rule
if not object_lock or object_lock["ObjectLockEnabled"] != "Enabled" or not object_lock["Rule"]:
return False
# Ensure ObjectLockConfiguration is in COMPLIANCE mode, not GOVERNANCE mode
if deep_get(object_lock, "Rule", "DefaultRetention", "Mode") != "COMPLIANCE":
return False
return (
deep_get(object_lock, "Rule", "DefaultRetention", "Days", default=0)
>= RETENTION_PERIOD_DAYS
)
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_object_lock_configured.py
PolicyID: "AWS.S3.BucketObjectLockConfigured"
DisplayName: "AWS S3 Bucket Object Lock Configured"
Enabled: false
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- S3
- PCI
- Impact:Data Destruction
Reports:
PCI:
- 10.5.3
MITRE ATT&CK:
- TA0040:T1485
Severity: Low
Description: >
This policy validates that S3 buckets have an Object Lock configuration enabled. This should be used with specific suppression lists to ensure it is applied only to appropriate S3 buckets, such as those containing CloudTrail or other auditable records.
Runbook: Create a new S3 bucket with an appropriate Object Lock configuration and point audit records to that bucket.
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lock.html
Stages and Predicates
Flags AWS.S3.Bucket resources when any of the conditions below holds.
Condition
any of:
ObjectLockConfigurationis emptyObjectLockConfiguration.ObjectLockEnabledis notEnabledObjectLockConfiguration.Ruleis emptyObjectLockConfiguration.Rule.DefaultRetention.Modeis notCOMPLIANCEObjectLockConfiguration.Rule.DefaultRetention.Daysis less than365
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
ObjectLockConfiguration | is_null | excludes:ObjectLockConfiguration | |
ObjectLockConfiguration.ObjectLockEnabled | ne | Enabled | excludes:ObjectLockConfiguration.ObjectLockEnabled field:"ObjectLockConfiguration.ObjectLockEnabled" value:"Enabled" |
ObjectLockConfiguration.Rule | is_null | excludes:ObjectLockConfiguration.Rule | |
ObjectLockConfiguration.Rule.DefaultRetention.Days | ge | 365 | excludes:ObjectLockConfiguration.Rule.DefaultRetention.Days field:"ObjectLockConfiguration.Rule.DefaultRetention.Days" value:"365" |
ObjectLockConfiguration.Rule.DefaultRetention.Mode | eq | COMPLIANCE | excludes:ObjectLockConfiguration.Rule.DefaultRetention.Mode field:"ObjectLockConfiguration.Rule.DefaultRetention.Mode" value:"COMPLIANCE" |
Indicators
These rows show field, operator, and value matches.
Response runbook
Create a new S3 bucket with an appropriate Object Lock configuration and point audit records to that bucket.
AWS S3 Bucket Policy Allow With Not Principal
#Prevents the use of a 'Not' principal in conjunction with an allow effect in an S3 bucket policy, which would allow global access for the resource besides the principals specified.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
import json
from policyuniverse.policy import Policy
def policy(resource):
if resource["Policy"] is None:
return True
iam_policy = Policy(json.loads(resource["Policy"]))
for statement in iam_policy.statements:
if statement.effect == "Allow" and statement.uses_not_principal():
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_policy_allow_with_not_principal.py
PolicyID: "AWS.S3.Bucket.PolicyAllowWithNotPrincipal"
DisplayName: "AWS S3 Bucket Policy Allow With Not Principal"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Identity & Access Management
- Collection:Data From Cloud Storage Object
Reports:
MITRE ATT&CK:
- TA0009:T1530
Severity: High
Description: >
Prevents the use of a 'Not' principal in conjunction with an allow effect in an S3 bucket policy, which would allow global access for the resource besides the principals specified.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-policy-does-not-use-allow-with-not-principal
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notprincipal.html
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
Policyis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Policy | is_null | excludes:Policy |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Policy | is_not_null | field:"Policy" kind:is_not_null |
Response runbook
AWS S3 Bucket Policy Modified
#An S3 Bucket was modified.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- ASL AWS Defense Evasion PutBucketLifecycle (Splunk)
- AWS CloudTrail Retention Lifecycle Too Short (Panther)
- AWS Defense Evasion PutBucketLifecycle (Splunk)
- AWS S3 Bucket Expiration Lifecycle Configuration Added (Elastic)
- AWS S3 Bucket Replicated to Another Account (Elastic)
- AWSCloudTrail - S3 bucket exposed via ACL (Kusto)
- AWSCloudTrail - S3 bucket exposed via policy (Kusto)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of KMS CMK Deletion
S3_POLICY_CHANGE_EVENTS = {
"PutBucketAcl",
"PutBucketPolicy",
"PutBucketCors",
"PutBucketLifecycle",
"PutBucketReplication",
"DeleteBucketPolicy",
"DeleteBucketCors",
"DeleteBucketLifecycle",
"DeleteBucketReplication",
}
def rule(event):
return event.get("eventName") in S3_POLICY_CHANGE_EVENTS and aws_cloudtrail_success(event)
def title(event):
return f"S3 bucket modified by [{event.deep_get('userIdentity', 'arn')}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_s3_bucket_policy_modified.py
RuleID: "AWS.S3.BucketPolicyModified"
DisplayName: "AWS S3 Bucket Policy Modified"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Identity & Access Management
- Exfiltration:Exfiltration Over Web Service
Reports:
CIS:
- 3.8
MITRE ATT&CK:
- TA0010:T1567
Stratus Red Team:
- aws.defense-evasion.cloudtrail-lifecycle-rule
- aws.exfiltration.s3-backdoor-bucket-policy
Severity: Info
DedupPeriodMinutes: 720 # 12 hours
Description: >
An S3 Bucket was modified.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-s3-bucket-policy-modified
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/using-iam-policies.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameis one ofPutBucketAcl,PutBucketPolicy,PutBucketCors,PutBucketLifecycle,PutBucketReplicationerrorCodeis emptyerrorMessageis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-s3-bucket-policy-modified
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"AuthenticationMethod": "AuthHeader",
"CipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"SignatureVersion": "SigV4"
},
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "PutBucketAcl",
"eventSource": "s3.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"acl": [
""
],
"bucketName": "bucket",
"host": [
"bucket.s3.us-west-2.amazonaws.com"
],
"x-amz-acl": [
"private"
]
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "tester"
}
},
"type": "AssumedRole"
}
}
AWS S3 Bucket Principal Restrictions
#This policy validates that S3 Bucket access policies do not allow all users (Principal:"*") for a given action on the bucket, in accordance with the principle of least privilege.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
import json
from policyuniverse.policy import Policy
BAD_PRINCIPALS = {
"*",
}
def policy(resource):
if resource["Policy"] is None:
return True
iam_policy = Policy(json.loads(resource["Policy"]))
for statement in iam_policy.statements:
# Only apply to allow effects
if statement.effect != "Allow":
continue
# Don't apply where there are strong conditions
if statement.condition_entries:
continue
if BAD_PRINCIPALS.intersection(statement.principals):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_principal_restrictions.py
PolicyID: "AWS.S3.Bucket.PrincipalRestrictions"
DisplayName: "AWS S3 Bucket Principal Restrictions"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Identity & Access Management
- Collection:Data From Cloud Storage Object
Reports:
PCI:
- 10.5.1
- 10.5.2
MITRE ATT&CK:
- TA0009:T1530
Severity: High
Description: >
This policy validates that S3 Bucket access policies do not allow all users (Principal:"*") for a given action on the bucket, in accordance with the principle of least privilege.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-policy-restricts-principal
Reference: https://aws.amazon.com/premiumsupport/knowledge-center/secure-s3-resources/
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
Policyis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Policy | is_null | excludes:Policy |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Policy | is_not_null | field:"Policy" kind:is_not_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-policy-restricts-principal
AWS S3 Bucket Public Access Block
#Ensures that a Public Access Block Configuration is set for the given S3 bucket.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
def policy(resource):
return any((resource["PublicAccessBlockConfiguration"] or {}).values())
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_public_access_block.py
PolicyID: "AWS.S3.Bucket.PublicAccessBlock"
DisplayName: "AWS S3 Bucket Public Access Block"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Data Protection
- Collection:Data From Cloud Storage Object
Reports:
PCI:
- 2.2.3
MITRE ATT&CK:
- TA0009:T1530
Severity: Medium
Description: >
Ensures that a Public Access Block Configuration is set for the given S3 bucket.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-public-access-block-enabled
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/access-control-block-public-access.html
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
PublicAccessBlockConfigurationis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
PublicAccessBlockConfiguration | is_not_null | excludes:PublicAccessBlockConfiguration |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
PublicAccessBlockConfiguration | is_null | field:"PublicAccessBlockConfiguration" kind:is_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-public-access-block-enabled
AWS S3 Bucket Public Read
#Ensures that the S3 bucket is not publicly readable.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
from panther_base_helpers import deep_get
GRANTEES = {
"http://acs.amazonaws.com/groups/global/AuthenticatedUsers",
"http://acs.amazonaws.com/groups/global/AllUsers",
}
PERMISSIONS = {"READ"}
def policy(resource):
for grant in resource["Grants"] or []:
if deep_get(grant, "Grantee", "URI") in GRANTEES and grant.get("Permission") in PERMISSIONS:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_public_read.py
PolicyID: "AWS.S3.Bucket.PublicRead"
DisplayName: "AWS S3 Bucket Public Read"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Identity & Access Management
- Data Protection
- Collection:Data From Cloud Storage Object
Reports:
PCI:
- 10.5.1
MITRE ATT&CK:
- TA0009:T1530
Severity: High
Description: >
Ensures that the S3 bucket is not publicly readable.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-not-publicly-readable
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/access-control-block-public-access.html
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
any element of
Grantsmatches all of:Grants.Grantee.URIis one ofhttp://acs.amazonaws.com/groups/global/AuthenticatedUsers,http://acs.amazonaws.com/groups/global/AllUsersGrants.Permissionis one ofREAD
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-not-publicly-readable
AWS S3 Bucket Public Write
#Ensures S3 buckets are not publicly writeable, preventing critical security vulnerabilities. Public write access allows attackers to upload malicious content, delete legitimate data, or consume storage for massive AWS bills. Attackers can use writable buckets for C2 infrastructure, malware distribution, or supply chain attacks.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
from panther_base_helpers import deep_get
GRANTEES = {
"http://acs.amazonaws.com/groups/global/AuthenticatedUsers",
"http://acs.amazonaws.com/groups/global/AllUsers",
}
PERMISSIONS = {"WRITE", "WRITE_ACP", "FULL_CONTROL"}
def policy(resource):
for grant in resource["Grants"] or []:
if deep_get(grant, "Grantee", "URI") in GRANTEES and grant.get("Permission") in PERMISSIONS:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_public_write.py
PolicyID: "AWS.S3.Bucket.PublicWrite"
DisplayName: "AWS S3 Bucket Public Write"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Data Protection
- Identity & Access Management
- Impact:Data Destruction
Reports:
CIS:
- S3.8
PCI:
- 1.3.1
- 7.1
- 7.2.1
MITRE ATT&CK:
- TA0040:T1485
Severity: Critical
Description: >
Ensures S3 buckets are not publicly writeable, preventing critical security vulnerabilities. Public write access allows attackers to upload malicious content, delete legitimate data, or consume storage for massive AWS bills. Attackers can use writable buckets for C2 infrastructure, malware distribution, or supply chain attacks.
Runbook: |
1. Query CloudTrail for s3:PutObject, s3:DeleteObject, and s3:PutObjectAcl events on the bucket in the past 30 days to identify any unauthorized write operations from unfamiliar IP addresses or principals
2. Remove public write permissions from the bucket's ACL and bucket policy, enable all four S3 Block Public Access settings, and quarantine or delete any malicious objects that were uploaded
3. Enable S3 server access logging and CloudTrail S3 data events for ongoing monitoring, and review how the public write access was configured to prevent recurrence
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/using-iam-policies.html
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
any element of
Grantsmatches all of:Grants.Grantee.URIis one ofhttp://acs.amazonaws.com/groups/global/AuthenticatedUsers,http://acs.amazonaws.com/groups/global/AllUsersGrants.Permissionis one ofWRITE,WRITE_ACP,FULL_CONTROL
Response runbook
1. Query CloudTrail for s3:PutObject, s3:DeleteObject, and s3:PutObjectAcl events on the bucket in the past 30 days to identify any unauthorized write operations from unfamiliar IP addresses or principals
2. Remove public write permissions from the bucket's ACL and bucket policy, enable all four S3 Block Public Access settings, and quarantine or delete any malicious objects that were uploaded
3. Enable S3 server access logging and CloudTrail S3 data events for ongoing monitoring, and review how the public write access was configured to prevent recurrence
AWS S3 Bucket Secure Access
#Ensures access to S3 buckets is forced to use a secure (HTTPS) connection.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
import json
from panther_base_helpers import deep_get
from policyuniverse.policy import Policy
# According to AWS there should exist an explicit deny policy that contains
# "aws:SecureTransport": "false" for this check to be compliant
# https://aws.amazon.com/premiumsupport/knowledge-center/s3-bucket-policy-for-config-rule/
# This policy returns a LOW severity alert if the bucket policy is using an implicit deny
# of secure transport or a HIGH severity if secure transport is not enforced.
IMPLICIT_DENY = True
def policy(resource):
if resource["Policy"] is None:
return False
explicit_deny = False
global IMPLICIT_DENY # pylint: disable=global-statement
# Reset global to prevent it getting stepped on by reuse in the Lambda invocation
IMPLICIT_DENY = True
iam_policy = Policy(json.loads(resource["Policy"]))
for statement in iam_policy.statements:
if (
statement.effect == "Deny"
and deep_get(statement.statement, "Condition", "Bool", "aws:SecureTransport") != "true"
):
explicit_deny = True
break
if (
statement.effect == "Allow"
and deep_get(statement.statement, "Condition", "Bool", "aws:SecureTransport") != "true"
):
IMPLICIT_DENY = False
return explicit_deny
def severity(_):
if IMPLICIT_DENY:
return "LOW"
return "HIGH"
def title(resource):
if IMPLICIT_DENY:
return f"{resource.get('Name')} lacks an explicit deny policy for Secure Transport"
return f"{resource.get('Name')} does not enforce Secure Transport"
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_secure_access.py
PolicyID: "AWS.S3.Bucket.SecureAccess"
DisplayName: "AWS S3 Bucket Secure Access"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Security Control
- Collection:Data From Cloud Storage Object
Reports:
PCI:
- 2.2.3
- 4.1
MITRE ATT&CK:
- TA0009:T1530
Severity: Low
Description: >
Ensures access to S3 buckets is forced to use a secure (HTTPS) connection.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-policy-enforces-secure-access
Reference: https://aws.amazon.com/premiumsupport/knowledge-center/secure-s3-resources/
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
Policyis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Policy | is_not_null | excludes:Policy |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Policy | is_null | field:"Policy" kind:is_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
Name |
Response runbook
AWS S3 Bucket Versioning
#Checks that object versioning is enabled in the S3 bucket.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
def policy(resource):
return resource["Versioning"] == "Enabled"
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_versioning.py
PolicyID: "AWS.S3.Bucket.Versioning"
DisplayName: "AWS S3 Bucket Versioning"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Security Control
- Impact:Data Destruction
Reports:
PCI:
- 10.5.3
- 10.5.5
MITRE ATT&CK:
- TA0040:T1485
Severity: Low
Description: >
Checks that object versioning is enabled in the S3 bucket.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-versioning-enabled
Reference: https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
Versioningis notEnabled
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Versioning | eq | Enabled | excludes:Versioning field:"Versioning" value:"Enabled" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Versioning | ne |
| field:"Versioning" kind:ne value:"Enabled" |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-s3-bucket-versioning-enabled
AWS S3 Copy Object with Client-Side Encryption
#This rule detects when objects are copied in an S3 bucket with client-side encryption. Such actions can be indicative of unauthorized data access or other suspicious activities.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact | No specific technique |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "s3.amazonaws.com"
and event.get("eventName") == "CopyObject"
and event.deep_get("requestParameters", "x-amz-server-side-encryption-customer-algorithm")
is not None
)
def title(event):
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"copied many objects on "
f"[{event.deep_get('requestParameters', 'bucketName')}] bucket"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
DedupPeriodMinutes: 60
DisplayName: AWS S3 Copy Object with Client-Side Encryption
Enabled: true
Filename: aws_s3_copy_object_with_client_side_encryption.py
RuleID: "AWS.S3.CopyObjectWithClientSideEncryption"
Severity: Medium
Threshold: 50
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- S3
- CloudTrail
- Ransomware
- Impact:Data Destruction
Reports:
Stratus Red Team:
- aws.impact.s3-ransomware-client-side-encryption
Description: >
This rule detects when objects are copied in an S3 bucket with client-side encryption. Such actions can be indicative of unauthorized data access or other suspicious activities.
Runbook: |
Investigate the user and the actions performed on the S3 bucket to ensure they were authorized. Unauthorized copying of encrypted objects can lead to data exposure.
Steps to investigate:
1. Identify the user who performed the action.
2. Verify if the action was authorized.
3. Check for any other suspicious activities performed by the same user.
4. If unauthorized, take necessary actions to secure the S3 bucket and prevent further unauthorized access.
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/logging-with-cloudtrail.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceiss3.amazonaws.comeventNameisCopyObjectrequestParameters.x-amz-server-side-encryption-customer-algorithmis present
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
Investigate the user and the actions performed on the S3 bucket to ensure they were authorized. Unauthorized copying of encrypted objects can lead to data exposure.
Steps to investigate:
1. Identify the user who performed the action.
2. Verify if the action was authorized.
3. Check for any other suspicious activities performed by the same user.
4. If unauthorized, take necessary actions to secure the S3 bucket and prevent further unauthorized access.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"CipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"SignatureVersion": "SigV4",
"bytesTransferredIn": 0,
"bytesTransferredOut": 0
},
"awsRegion": "us-east-1",
"eventID": "EXAMPLE-1234-5678-9012-EXAMPLE",
"eventName": "CopyObject",
"eventSource": "s3.amazonaws.com",
"eventTime": "2023-10-01T12:34:56Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": false,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "EXAMPLE123456789",
"requestParameters": {
"bucketName": "example-bucket",
"key": "example-object",
"x-amz-server-side-encryption-customer-algorithm": "AES256"
},
"resources": [
{
"ARN": "arn:aws:s3:::example-bucket/example-object",
"type": "AWS::S3::Object"
}
],
"responseElements": null,
"sharedEventID": "EXAMPLE-1234-5678-9012-EXAMPLE",
"sourceIPAddress": "192.0.2.0",
"userAgent": "aws-sdk-go/1.15.12 (go1.12.6; linux; amd64)",
"userIdentity": {
"accessKeyId": "EXAMPLEKEY",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/Alice",
"principalId": "EXAMPLE",
"type": "IAMUser",
"userName": "Alice"
},
"vpcEndpointId": "vpce-1a2b3c4d"
}
AWS S3 Delete Object Detection
#This rule detects when many objects are deleted from an S3 bucket. Such actions can be indicative of unauthorized data deletion or other suspicious activities.
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event DeleteObject: Removes an object from a specified S3 bucket. |
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "s3.amazonaws.com"
and event.get("eventName") == "DeleteObject"
)
def title(event):
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"deleted many items from the "
f"[{event.deep_get('requestParameters', 'bucketName')}] bucket"
)
def alert_context(event):
context = aws_rule_context(event)
context["bucketName"] = event.deep_get(
"requestParameters", "bucketName", default="<UNKNOWN_BUCKET>"
)
return context
Rule specification
AnalysisType: rule
DedupPeriodMinutes: 60
DisplayName: AWS S3 Delete Object Detection
Enabled: true
Filename: aws_s3_delete_object.py
RuleID: "AWS.S3.DeleteObject"
Threshold: 50
Severity: Info
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- S3
- CloudTrail
Reports:
Stratus Red Team:
- aws.impact.s3-ransomware-batch-deletion
- aws.impact.s3-ransomware-client-side-encryption
- aws.impact.s3-ransomware-individual-deletion
Description: >
This rule detects when many objects are deleted from an S3 bucket. Such actions can be indicative of unauthorized data deletion or other suspicious activities.
Runbook: |
Investigate the user and the actions performed on the S3 bucket to ensure they were authorized. Unauthorized deletions can lead to data loss.
Steps to investigate:
1. Identify the user who performed the action.
2. Verify if the action was authorized.
3. Check for any other suspicious activities performed by the same user.
4. If unauthorized, take necessary actions to secure the S3 bucket and prevent further unauthorized access.
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/delete-objects.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceiss3.amazonaws.comeventNameisDeleteObject
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"DeleteObject" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"s3.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
Investigate the user and the actions performed on the S3 bucket to ensure they were authorized. Unauthorized deletions can lead to data loss.
Steps to investigate:
1. Identify the user who performed the action.
2. Verify if the action was authorized.
3. Check for any other suspicious activities performed by the same user.
4. If unauthorized, take necessary actions to secure the S3 bucket and prevent further unauthorized access.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"CipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"SignatureVersion": "SigV4",
"bytesTransferredIn": 0,
"bytesTransferredOut": 0
},
"awsRegion": "us-east-1",
"eventID": "EXAMPLE-1234-5678-9012-EXAMPLE",
"eventName": "DeleteObject",
"eventSource": "s3.amazonaws.com",
"eventTime": "2023-10-01T12:34:56Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": false,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "EXAMPLE123456789",
"requestParameters": {
"bucketName": "example-bucket",
"key": "example-object"
},
"resources": [
{
"ARN": "arn:aws:s3:::example-bucket/example-object",
"type": "AWS::S3::Object"
}
],
"responseElements": null,
"sharedEventID": "EXAMPLE-1234-5678-9012-EXAMPLE",
"sourceIPAddress": "192.0.2.0",
"userAgent": "aws-sdk-go/1.15.12 (go1.12.6; linux; amd64)",
"userIdentity": {
"accessKeyId": "EXAMPLEKEY",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/Alice",
"principalId": "EXAMPLE",
"type": "IAMUser",
"userName": "Alice"
},
"vpcEndpointId": "vpce-1a2b3c4d"
}
AWS S3 Delete Objects Detection
#This rule detects when multiple objects are deleted from an S3 bucket. Such actions can be indicative of unauthorized data deletion or other suspicious activities.
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "s3.amazonaws.com"
and event.get("eventName") in ("DeleteObjects", "DeleteObjectVersion")
)
def title(event):
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"deleted many objects on "
f"[{event.deep_get('requestParameters', 'bucketName')}] bucket"
)
def alert_context(event):
context = aws_rule_context(event)
context["bucketName"] = event.deep_get(
"requestParameters", "bucketName", default="UNKNOWN_BUCKET"
)
return context
Rule specification
AnalysisType: rule
DedupPeriodMinutes: 60
DisplayName: AWS S3 Delete Objects Detection
Enabled: true
Filename: aws_s3_delete_objects.py
RuleID: "AWS.S3.DeleteObjects"
Threshold: 1
Severity: Info
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- S3
- CloudTrail
Reports:
Stratus Red Team:
- aws.impact.s3-ransomware-batch-deletion
Description: >
This rule detects when multiple objects are deleted from an S3 bucket. Such actions can be indicative of unauthorized data deletion or other suspicious activities.
Runbook: |
Investigate the user and the actions performed on the S3 bucket to ensure they were authorized. Unauthorized deletions can lead to data loss.
Steps to investigate:
1. Identify the user who performed the action.
2. Verify if the action was authorized.
3. Check for any other suspicious activities performed by the same user.
4. If unauthorized, take necessary actions to secure the S3 bucket and prevent further unauthorized access.
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/logging-with-cloudtrail.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceiss3.amazonaws.comeventNameis one ofDeleteObjects,DeleteObjectVersion
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"s3.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
Investigate the user and the actions performed on the S3 bucket to ensure they were authorized. Unauthorized deletions can lead to data loss.
Steps to investigate:
1. Identify the user who performed the action.
2. Verify if the action was authorized.
3. Check for any other suspicious activities performed by the same user.
4. If unauthorized, take necessary actions to secure the S3 bucket and prevent further unauthorized access.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"CipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"SignatureVersion": "SigV4",
"bytesTransferredIn": 0,
"bytesTransferredOut": 0
},
"awsRegion": "us-east-1",
"eventID": "EXAMPLE-1234-5678-9012-EXAMPLE",
"eventName": "DeleteObjects",
"eventSource": "s3.amazonaws.com",
"eventTime": "2023-10-01T12:34:56Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": false,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "EXAMPLE123456789",
"requestParameters": {
"bucketName": "example-bucket",
"key": "example-object"
},
"resources": [
{
"ARN": "arn:aws:s3:::example-bucket/example-object",
"type": "AWS::S3::Object"
}
],
"responseElements": null,
"sharedEventID": "EXAMPLE-1234-5678-9012-EXAMPLE",
"sourceIPAddress": "192.0.2.0",
"userAgent": "aws-sdk-go/1.15.12 (go1.12.6; linux; amd64)",
"userIdentity": {
"accessKeyId": "EXAMPLEKEY",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/Alice",
"principalId": "EXAMPLE",
"type": "IAMUser",
"userName": "Alice"
},
"vpcEndpointId": "vpce-1a2b3c4d"
}
AWS S3 Insecure Access
#Checks if HTTP (unencrypted) was used to access objects in an S3 bucket, as opposed to HTTPS (encrypted).
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
from panther_aws_helpers import aws_rule_context
from panther_base_helpers import pattern_match
def rule(event):
return pattern_match(event.get("operation", ""), "REST.*.OBJECT") and (
not event.get("ciphersuite") or not event.get("tlsVersion")
)
def title(event):
return f"Insecure access to S3 Bucket [{event.get('bucket', '<UNKNOWN_BUCKET>')}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_s3_insecure_access.py
RuleID: "AWS.S3.ServerAccess.Insecure"
DisplayName: "AWS S3 Insecure Access"
DedupPeriodMinutes: 720 # 12 hours
Enabled: true
LogTypes:
- AWS.S3ServerAccess
Tags:
- AWS
- Configuration Required
- Security Control
- Collection:Data From Cloud Storage Object
Reports:
MITRE ATT&CK:
- TA0009:T1530
Severity: Low
Description: >
Checks if HTTP (unencrypted) was used to access objects in an S3 bucket, as opposed to HTTPS (encrypted).
Runbook: >
Add a condition on the S3 bucket policy that denies access via http.
Reference: https://aws.amazon.com/premiumsupport/knowledge-center/s3-bucket-policy-for-config-rule/
SummaryAttributes:
- bucket
- key
- operation
- userAgent
- remoteip
- requester
- p_any_aws_arns
- p_any_aws_account_ids
Stages and Predicates
Fires on AWS.S3ServerAccess events when all of the conditions below hold.
Condition
operationmatches the patternREST.*.OBJECTany of:
ciphersuiteis emptytlsVersionis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
ciphersuite | is_null | field:"ciphersuite" kind:is_null | |
operation | wildcard |
| field:"operation" kind:wildcard value:"REST.*.OBJECT" |
tlsVersion | is_null | field:"tlsVersion" kind:is_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
bucket |
Response runbook
Add a condition on the S3 bucket policy that denies access via http.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"authenticationtype": "AuthHeader",
"bucket": "cloudtrail",
"bucketowner": "f16a9e81a6589df1c902c86f7982fd14a88787db",
"hostheader": "cloudtrail.s3.us-east-1.amazonaws.com",
"hostid": "neRpT/AXRsS3LMBqq/wND59opwPRWWKn7F6evEhdbS99me5fyIXpVI/MMIn6ECgU1YZAqwuF8Bw=",
"httpstatus": 200,
"key": "AWSLogs/o-wwwwwwgggg/234567890123/CloudTrail-Digest/ca-central-1/2020/02/14/234567890123_CloudTrail-Digest_ca-central-1_POrgTrail_us-east-1_20200214T001007Z.json.gz",
"objectsize": 747,
"operation": "REST.PUT.OBJECT",
"p_any_aws_arns": [
"arn:aws:sts::123456789012:assumed-role/eagle/regionalDeliverySession"
],
"p_any_ip_addresses": [
"55.99.86.234"
],
"p_event_time": "2020-02-14 00:53:48.000000000",
"p_log_type": "AWS.S3ServerAccess",
"p_row_id": "8855aa99ff77abc8dcb0e36e0a",
"remoteip": "127.0.0.1",
"requester": "arn:aws:sts::123456789012:assumed-role/eagle/regionalDeliverySession",
"requestid": "101B7403B9828743",
"requesturi": "PUT /AWSLogs/o-wwwwwwgggg/234567890123/CloudTrail-Digest/ca-central-1/2020/02/14/234567890123_CloudTrail-Digest_ca-central-1_POrgTrail_us-east-1_20200214T001007Z.json.gz HTTP/1.1",
"signatureversion": "SigV4",
"time": "2020-02-14 00:53:48.000000000",
"totaltime": 110,
"turnaroundtime": 20,
"useragent": "aws-internal/3 aws-sdk-java/1.11.714 Linux/4.9.184-0.1.ac.235.83.329.metal1.x86_64 OpenJDK_64-Bit_Server_VM/25.242-b08 java/1.8.0_242 vendor/Oracle_Corporation"
}
AWS S3 Large Download
#Detects when a user (IAM User, AssumedRole, or FederatedUser) downloads more than the configured threshold of data from S3 buckets within a time window. Configurable thresholds and bucket filtering allow customization for different organizational needs. This may indicate unauthorized data exfiltration or bulk data downloads for analysis.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event GetObject: Retrieves an object from a specified S3 bucket. |
Rules detecting the same action
These rules filter on the same operation.
- AWS S3 Credential File Retrieved from Bucket (Elastic)
- AWS S3 Large Download (Panther)
- AWS S3 Unauthenticated Bucket Access by Rare Source (Elastic)
Detection logic
def rule(_) -> bool:
"""Always return True since the query already filtered for violations"""
return True
def title(event) -> str:
user_arn = event.get("user_arn", "unknown")
bucket_name = event.get("bucket_name", "unknown")
total_mb = round(event.get("total_bytes_downloaded", 0) / (1024 * 1024), 2)
return f"Large S3 download detected: {user_arn} downloaded {total_mb}MB from {bucket_name}"
def alert_context(event) -> dict:
total_bytes = event.get("total_bytes_downloaded", 0)
total_mb = round(total_bytes / (1024 * 1024), 2)
return {
"user_arn": event.get("user_arn"),
"user_name": event.get("user_name"),
"bucket_name": event.get("bucket_name"),
"source_ip": event.get("source_ip"),
"user_agent": event.get("user_agent"),
"total_bytes_downloaded": total_bytes,
"total_mb_downloaded": total_mb,
"object_count": event.get("object_count"),
"first_download_time": event.get("first_download_time"),
"last_download_time": event.get("last_download_time"),
"sample_objects": event.get("sample_objects", [])[:10], # Show first 10 objects
}
Rule specification
AnalysisType: scheduled_rule
Filename: aws_s3_large_download_specific_bucket.py
RuleID: "AWS.S3.LargeDownload"
DisplayName: "AWS S3 Large Download"
Enabled: false
CreateAlert: false
ScheduledQueries:
- AWS S3 Large Download
Severity: Info
Tags:
- Beta
- Data Exfiltration
Reports:
MITRE ATT&CK:
- "TA0010:T1537" # Exfiltration: Transfer Data to Cloud Account
Description: >
Detects when a user (IAM User, AssumedRole, or FederatedUser) downloads more than the
configured threshold of data from S3 buckets within a time window. Configurable thresholds
and bucket filtering allow customization for different organizational needs. This may
indicate unauthorized data exfiltration or bulk data downloads for analysis.
DedupPeriodMinutes: 60
Runbook: |
1. **Immediate Actions:**
- Verify if the user activity is authorized
- Check if the downloads were for legitimate business purposes
- Consider temporarily restricting bucket access if suspicious
2. **Investigation Steps:**
- Review the user's recent authentication events
- Check for any privilege escalation or credential compromise
- Examine the downloaded objects to determine sensitivity
- Review source IP and user agent for signs of automation
- Check for other suspicious activities by the same user
3. **Containment:**
- If unauthorized: revoke user credentials immediately
- Apply temporary S3 bucket policies to restrict access
- Enable S3 MFA delete and additional monitoring
4. **Recovery:**
- Document the scope of data accessed
- Review S3 access logs for complete activity timeline
- Consider rotating any sensitive data that may have been accessed
5. **Prevention:**
- Implement S3 access monitoring and alerting
- Review IAM policies for least privilege
- Consider S3 Access Points for controlled access
- Enable GuardDuty for enhanced threat detection
Stages and Predicates
Rule logic
This rule alerts on rows returned by its scheduled query AWS S3 Large Download; its Python module (Detection logic above) shapes the alert rather than filtering.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
user_arn |
user_name |
bucket_name |
source_ip |
user_agent |
total_bytes_downloaded |
object_count |
first_download_time |
last_download_time |
Response runbook
1. Immediate Actions:
- Verify if the user activity is authorized
- Check if the downloads were for legitimate business purposes
- Consider temporarily restricting bucket access if suspicious
2. Investigation Steps:
- Review the user's recent authentication events
- Check for any privilege escalation or credential compromise
- Examine the downloaded objects to determine sensitivity
- Review source IP and user agent for signs of automation
- Check for other suspicious activities by the same user
3. Containment:
- If unauthorized: revoke user credentials immediately
- Apply temporary S3 bucket policies to restrict access
- Enable S3 MFA delete and additional monitoring
4. Recovery:
- Document the scope of data accessed
- Review S3 access logs for complete activity timeline
- Consider rotating any sensitive data that may have been accessed
5. Prevention:
- Implement S3 access monitoring and alerting
- Review IAM policies for least privilege
- Consider S3 Access Points for controlled access
- Enable GuardDuty for enhanced threat detection
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"account_id": "111111111111",
"bucket_name": "sensitive-data-bucket",
"first_download_time": "2025-01-15 14:30:00",
"last_download_time": "2025-01-15 14:34:30",
"object_count": 75,
"sample_objects": [
"logs/2025/01/15/file1.log",
"logs/2025/01/15/file2.log"
],
"source_ip": "3.3.3.3",
"total_bytes_downloaded": 78643200,
"user_agent": "aws-cli/2.0.0",
"user_arn": "arn:aws:iam::111111111111:user/data-engineer",
"user_name": "data-engineer",
"user_type": "IAMUser"
}
AWS S3 Large Download
#Returns S3 GetObject events where a user has downloaded more than the configured threshold of data within the specified time window. Supports filtering by bucket patterns and user types.
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event GetObject: Retrieves an object from a specified S3 bucket. |
Rules detecting the same action
These rules filter on the same operation.
- AWS S3 Credential File Retrieved from Bucket (Elastic)
- AWS S3 Large Download (Panther)
- AWS S3 Unauthenticated Bucket Access by Rare Source (Elastic)
Rule specification
AnalysisType: scheduled_query
QueryName: "AWS S3 Large Download"
Enabled: false
Description: >
Returns S3 GetObject events where a user has downloaded more than the configured threshold
of data within the specified time window. Supports filtering by bucket patterns and user types.
Tags:
- Beta
- Data Exfiltration
SnowflakeQuery: |-
SELECT
userIdentity:arn as user_arn,
COALESCE(userIdentity:userName, userIdentity:sessionContext:sessionIssuer:userName, 'unknown') as user_name,
userIdentity:type as user_type,
requestParameters:bucketName as bucket_name,
sourceIPAddress as source_ip,
userAgent as user_agent,
SUM(COALESCE(additionalEventData:bytesTransferredOut::int, 0)) as total_bytes_downloaded,
COUNT(*) as object_count,
MIN(p_event_time) as first_download_time,
MAX(p_event_time) as last_download_time,
ARRAY_AGG(DISTINCT requestParameters:key) as sample_objects
FROM panther_logs.public.aws_cloudtrail
WHERE eventName = 'GetObject'
AND eventSource = 's3.amazonaws.com'
AND userIdentity:type IN ('IAMUser', 'AssumedRole', 'FederatedUser')
AND errorCode IS NULL
AND p_event_time >= DATEADD(minute, -10, CURRENT_TIMESTAMP())
AND userIdentity:arn NOT LIKE '%panther-snowflake-api%'
-- AND (requestParameters:bucketName LIKE '%sensitive%' OR requestParameters:bucketName LIKE '%prod%')
GROUP BY
userIdentity:arn,
COALESCE(userIdentity:userName, userIdentity:sessionContext:sessionIssuer:userName, 'unknown'),
userIdentity:type,
requestParameters:bucketName,
sourceIPAddress,
userAgent
HAVING SUM(COALESCE(additionalEventData:bytesTransferredOut::int, 0)) >= 52428800 -- 50MB default
ORDER BY total_bytes_downloaded DESC
DatabricksQuery: |-
SELECT
userIdentity:arn as user_arn,
COALESCE(userIdentity:userName, userIdentity:sessionContext:sessionIssuer:userName, 'unknown') as user_name,
userIdentity:type as user_type,
requestParameters:bucketName as bucket_name,
sourceIPAddress as source_ip,
userAgent as user_agent,
SUM(COALESCE(CAST(additionalEventData:bytesTransferredOut::int AS BIGINT), 0)) as total_bytes_downloaded,
COUNT(*) as object_count,
MIN(p_event_time) as first_download_time,
MAX(p_event_time) as last_download_time,
COLLECT_SET(requestParameters:key) as sample_objects
FROM panther_logs.aws_cloudtrail
WHERE eventName = 'GetObject'
AND eventSource = 's3.amazonaws.com'
AND userIdentity:type IN ('IAMUser', 'AssumedRole', 'FederatedUser')
AND errorCode IS NULL
AND p_event_time >= CURRENT_TIMESTAMP() - INTERVAL 10 MINUTES
AND userIdentity:arn NOT LIKE '%panther-snowflake-api%'
-- AND (requestParameters:bucketName LIKE '%sensitive%' OR requestParameters:bucketName LIKE '%prod%')
GROUP BY
userIdentity:arn,
COALESCE(userIdentity:userName, userIdentity:sessionContext:sessionIssuer:userName, 'unknown'),
userIdentity:type,
requestParameters:bucketName,
sourceIPAddress,
userAgent
HAVING SUM(COALESCE(CAST(additionalEventData:bytesTransferredOut::int AS BIGINT), 0)) >= 52428800 -- 50MB default
ORDER BY total_bytes_downloaded DESC
Schedule:
RateMinutes: 10
TimeoutMinutes: 3
Stages and Predicates
Stage 1: source
Stage 2: filter
eventNameisGetObjecteventSourceiss3.amazonaws.comuserIdentity:typeis one ofIAMUser,AssumedRole,FederatedUsererrorCodeis emptyuserIdentity:arndoes not match the pattern*panther-snowflake-api*
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Stage 3: having
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
userIdentity:arn | match | panther-snowflake-api | excludes:userIdentity:arn field:"userIdentity:arn" value:"panther-snowflake-api" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | eq |
| field:"aws::eventName" kind:eq value:"GetObject" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"s3.amazonaws.com" |
userIdentity:type | in |
| field:"userIdentity:type" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
user_arn | userIdentity:arn |
user_name | COALESCE ( userIdentity:userName , userIdentity:sessionContext:sessionIssuer:userName , 'unknown' ) |
user_type | userIdentity:type |
bucket_name | requestParameters:bucketName |
source_ip | sourceIPAddress |
user_agent | userAgent |
total_bytes_downloaded | SUM ( COALESCE ( additionalEventData:bytesTransferredOut :: int , 0 ) ) |
object_count | COUNT ( * ) |
first_download_time | MIN ( p_event_time ) |
last_download_time | MAX ( p_event_time ) |
sample_objects | ARRAY_AGG ( DISTINCT requestParameters:key ) |
AWS S3 Object Copied to External Account Bucket
#Detects when an S3 object is copied from one bucket to another bucket in a different AWS account. This could indicate data exfiltration or a ransomware attack where data is copied to an attacker-controlled account.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration | |
| Impact |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def extract_resources(event):
resources = event.get("resources", [])
bucket_accounts = {}
if len(resources) > 0:
for resource in resources:
if resource.get("type") == "AWS::S3::Bucket":
bucket_name = resource.get("arn", "").split(":::")[-1]
account_id = resource.get("accountId", "")
bucket_accounts[bucket_name] = account_id
return bucket_accounts
def rule(event):
if event.get("eventName") != "CopyObject" or not aws_cloudtrail_success(event):
return False
bucket_accounts = extract_resources(event)
# Need at least 2 buckets to compare accounts
if len(bucket_accounts) < 2:
return False
# Check if buckets belong to different accounts
account_ids = set(bucket_accounts.values())
if len(account_ids) > 1:
return True
return False
def title(event):
dest_bucket = event.deep_get(
"requestParameters", "bucketName", default="<UNKNOWN_DESTINATION_BUCKET>"
)
source_bucket = event.deep_get(
"requestParameters", "x-amz-copy-source", default="<UNKNOWN_SOURCE_BUCKET>"
)
actor = event.udm("actor_user")
return (
f"[AWS.CloudTrail] User [{actor}] copied objects to external AWS account "
f"bucket [{dest_bucket}] from bucket [{source_bucket}]"
)
def alert_context(event):
context = aws_rule_context(event)
context["bucket_accounts"] = extract_resources(event)
context["dest_bucket"] = event.deep_get(
"requestParameters", "bucketName", default="<UNKNOWN_DESTINATION_BUCKET>"
)
# Extract just the bucket name from x-amz-copy-source (format: bucket/key)
source_path = event.deep_get(
"requestParameters", "x-amz-copy-source", default="<UNKNOWN_SOURCE_BUCKET>"
)
context["bucketName"] = source_path.split("/")[0] if "/" in source_path else source_path
return context
Rule specification
AnalysisType: rule
Filename: aws_s3_copy_object_to_external_account_bucket.py
RuleID: "AWS.S3.CopyObjectToExternalAccountBucket"
DisplayName: "AWS S3 Object Copied to External Account Bucket"
Enabled: true
CreateAlert: false
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Exfiltration:Transfer Data to Cloud Account
- Impact:Data Encrypted for Impact
Reports:
MITRE ATT&CK:
- TA0010:T1537
- TA0040:T1486
Severity: Medium
DedupPeriodMinutes: 60
Description: >
Detects when an S3 object is copied from one bucket to another bucket in a different AWS account.
This could indicate data exfiltration or a ransomware attack where data is copied to an attacker-controlled account.
Runbook: |
1. Query CloudTrail for all S3 API calls by the userIdentity:arn in the 24 hours before and after the alert to establish normal data access patterns
2. Check if the destination account ID appears in any legitimate cross-account S3 operations in the past 90 days
3. Find all CopyObject events to the same destination bucket from any user in the past 7 days to identify if this is part of a broader exfiltration campaign
Reference: https://www.trendmicro.com/en_us/research/25/k/s3-ransomware.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisCopyObjecterrorCodeis emptyerrorMessageis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | ne | CopyObject | excludes:eventName field:"eventName" value:"CopyObject" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
x-amz-copy-source | requestParameters.x-amz-copy-source |
Response runbook
1. Query CloudTrail for all S3 API calls by the userIdentity:arn in the 24 hours before and after the alert to establish normal data access patterns
2. Check if the destination account ID appears in any legitimate cross-account S3 operations in the past 90 days
3. Find all CopyObject events to the same destination bucket from any user in the past 7 days to identify if this is part of a broader exfiltration campaign
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "12345678-1234-1234-1234-222222222222",
"eventName": "CopyObject",
"eventSource": "s3.amazonaws.com",
"eventTime": "2024-01-15T12:00:00Z",
"eventType": "AwsApiCall",
"recipientAccountId": "12948575929274",
"requestID": "ABC123DEF456",
"requestParameters": {
"bucketName": "attacker-exfil-1764604156",
"key": "customer-database.csv",
"x-amz-copy-source": "ransomware-test-victim-1764604156/customer-database.csv"
},
"resources": [
{
"accountId": "111111111111",
"arn": "arn:aws:s3:::sample-bucket-magical-ardinghelli",
"type": "AWS::S3::Bucket"
},
{
"arn": "arn:aws:s3:::sample-bucket-magical-ardinghelli/customer-database.csv",
"type": "AWS::S3::Object"
},
{
"accountId": "12948575929274",
"arn": "arn:aws:s3:::sample-bucket-busy-carver",
"type": "AWS::S3::Bucket"
},
{
"arn": "arn:aws:s3:::sample-bucket-busy-carver/customer-database.csv",
"type": "AWS::S3::Object"
}
],
"responseElements": null,
"sourceIPAddress": "1.2.3.4",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "AKIA-MOCKACCESSKEYID-1",
"accountId": "12948575929274",
"arn": "arn:aws:sts::12948575929274:assumed-role/sample-role-keen-cohen/sample-role-hardcore-driscoll-role-brave-yalow-role-intelligent-brahmagupta-role-admiring-vaughan-role-sad-khayyam-role-eager-haslett",
"principalId": "AIDAI1234567890EXAMPLE:user",
"sessionContext": {
"attributes": {
"creationDate": "2024-01-15T10:30:00Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "12948575929274",
"arn": "arn:aws:iam::12948575929274:role/sample-role-keen-cohen",
"principalId": "AIDAI1234567890EXAMPLE",
"type": "Role",
"userName": "AdminRole"
}
},
"type": "AssumedRole"
}
}
AWS S3 Object Exfiltration WITH Object Deletion
#Detects a ransomware attack pattern where an attacker with compromised AWS credentials exfiltrates data from an S3 bucket to an external AWS account, followed by bulk deletion of objects from the source bucket within a short timeframe. This technique was notably used by the threat actor Bling Libra to extort victims by threatening data destruction or leaks.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration | |
| Impact |
Rule specification
AnalysisType: correlation_rule
RuleID: "AWS.S3.ObjectExfiltration.WITH.ObjectDeletion"
DisplayName: "AWS S3 Object Exfiltration WITH Object Deletion"
Enabled: false
Severity: High
Description: >
Detects a ransomware attack pattern where an attacker with compromised AWS credentials exfiltrates data from an S3 bucket
to an external AWS account, followed by bulk deletion of objects from the source bucket within a short timeframe.
This technique was notably used by the threat actor Bling Libra to extort victims by threatening data destruction or leaks.
Runbook: |
1. Query CloudTrail for all S3 API calls by the userIdentity:arn in the 24 hours before and after the alert to establish the full scope of bucket access
2. Verify if the destination account ID in the CopyObject events appears in any legitimate cross-account S3 operations in the past 90 days
3. Check if the source IP addresses for both exfiltration and deletion events match known VPN endpoints, cloud provider ranges, or previously seen IPs for this user
4. Find all other CopyObject and DeleteObject events to the same or other buckets from any user in the past 7 days to identify if this is part of a broader campaign
Reference: https://www.trendmicro.com/en_us/research/25/k/s3-ransomware.html
Tags:
- AWS
- Exfiltration:Transfer Data to Cloud Account
- Impact:Data Encrypted for Impact
Reports:
MITRE ATT&CK:
- TA0010:T1537
- TA0040:T1486
Detection:
- Group:
- ID: Bulk Exfiltration
RuleID: AWS.S3.CopyObjectToExternalAccountBucket
MinMatchCount: 10
- ID: Bulk Deletion
RuleID: AWS.S3.DeleteObject
MinMatchCount: 10
MatchCriteria:
field_name:
- GroupID: Bulk Exfiltration
Match: p_alert_context.bucketName
- GroupID: Bulk Deletion
Match: p_alert_context.bucketName
Schedule:
RateMinutes: 1440
TimeoutMinutes: 10
LookbackWindowMinutes: 1800
Stages and Predicates
Fires when the steps below all occur within 30h, correlated by p_alert_context.bucketName. Each step needs one match unless a higher minimum is shown.
Stage 1: step Bulk Exfiltration
References detection AWS S3 Object Copied to External Account Bucket (min 10 matches).
Stage 2: step Bulk Deletion
References detection AWS S3 Delete Object Detection (min 10 matches).
Response runbook
1. Query CloudTrail for all S3 API calls by the userIdentity:arn in the 24 hours before and after the alert to establish the full scope of bucket access
2. Verify if the destination account ID in the CopyObject events appears in any legitimate cross-account S3 operations in the past 90 days
3. Check if the source IP addresses for both exfiltration and deletion events match known VPN endpoints, cloud provider ranges, or previously seen IPs for this user
4. Find all other CopyObject and DeleteObject events to the same or other buckets from any user in the past 7 days to identify if this is part of a broader campaign
AWS S3 Ransomware Note Upload Detection
#This rule detects when files with names commonly associated with ransomware notes are uploaded to S3 buckets. Ransomware attackers often drop ransom notes with distinctive filenames like HOW_TO_DECRYPT_FILES.txt, RANSOM_NOTE.txt, FILES_ENCRYPTED.html, or similar patterns to inform victims about the encryption and provide payment instructions.
Detection logic
import re
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# Common ransomware note filename patterns
RANSOM_NOTE_PATTERNS = [
# Explicit ransomware-related terms
# RANSOM_NOTE.txt, PAYMENT_INFO.html
r"(?i)(ransom|payment)[_-]?(note|info|instructions?).*\.(txt|html?)$",
# Decrypt/restore with specific action words
# HOW_TO_DECRYPT_FILES.txt
r"(?i)how[_-]?to[_-]?(decrypt|restore|recover)[_-]?(your[_-]?)?files.*\.(txt|html?)$",
# DECRYPT_INSTRUCTIONS.txt
r"(?i)decrypt[_-]?(instructions?|guide|info|your[_-]?files).*\.(txt|html?)$",
# RESTORE_INSTRUCTIONS.txt
r"(?i)restore[_-]?(instructions?|guide|info|your[_-]?files).*\.(txt|html?)$",
# RECOVERY_INSTRUCTIONS.txt
r"(?i)recovery[_-]?(instructions?|key|guide).*\.(txt|html?)$",
# Files encrypted/locked messages
# FILES_ENCRYPTED.txt, ALL_FILES_HAVE_BEEN_ENCRYPTED.txt
r"(?i)(all[_-]?)?files?[_-]?(have[_-]?been[_-]?)?(encrypted|locked).*\.(txt|html?)$",
# YOUR_FILES_ARE_ENCRYPTED.txt
r"(?i)your[_-]?files?[_-]?(are|have[_-]?been)[_-]?(encrypted|locked).*\.(txt|html?)$",
# DATA_ENCRYPTED.txt
r"(?i)data[_-]?(has[_-]?been[_-]?)?(encrypted|locked).*\.(txt|html?)$",
# Unlock-related (common in ransomware)
# UNLOCK_INSTRUCTIONS.txt
r"(?i)unlock[_-]?(instructions?|guide|your[_-]?files).*\.(txt|html?)$",
# Help decrypt/restore (specific to ransomware)
# HELP_DECRYPT_YOUR_FILES.txt
r"(?i)help[_-]?(restore|decrypt|recover)[_-]?(your[_-]?)?files.*\.(txt|html?)$",
]
COMPILED_PATTERNS = [re.compile(pattern) for pattern in RANSOM_NOTE_PATTERNS]
def extract_filename(event):
key = event.deep_get("requestParameters", "key", default="")
if not key:
resources = event.get("resources", [])
for resource in resources:
if resource.get("type") == "AWS::S3::Object":
arn = resource.get("arn", "")
# Extract key from ARN (format: arn:aws:s3:::bucket/key)
if "/" in arn:
key = arn.split("/", 1)[1]
break
filename = key.split("/")[-1] if "/" in key else key
return filename
def rule(event):
if event.get("eventName") != "PutObject" or not aws_cloudtrail_success(event):
return False
filename = extract_filename(event)
# Check if filename matches any ransomware note pattern
return any(pattern.match(filename) for pattern in COMPILED_PATTERNS)
def title(event):
bucket = event.deep_get("requestParameters", "bucketName", default="<UNKNOWN_BUCKET>")
filename = extract_filename(event)
return (
f"[AWS.CloudTrail] Potential ransomware note uploaded to S3: "
f"[{filename}] in bucket [{bucket}] by user [{event.udm('actor_user')}]"
)
def alert_context(event):
context = aws_rule_context(event)
key = event.deep_get("requestParameters", "key", default="<UNKNOWN_KEY>")
context["bucketName"] = event.deep_get(
"requestParameters", "bucketName", default="<UNKNOWN_BUCKET>"
)
context["objectKey"] = key
context["filename"] = extract_filename(event)
return context
Rule specification
AnalysisType: rule
DedupPeriodMinutes: 60
DisplayName: AWS S3 Ransomware Note Upload Detection
Enabled: true
Filename: aws_s3_ransomware_note_upload.py
RuleID: "AWS.S3.RansomwareNoteUpload"
Severity: Medium
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- S3
- CloudTrail
- Ransomware
- DataSecurity
Description: >
This rule detects when files with names commonly associated with ransomware notes are uploaded to S3 buckets.
Ransomware attackers often drop ransom notes with distinctive filenames like HOW_TO_DECRYPT_FILES.txt,
RANSOM_NOTE.txt, FILES_ENCRYPTED.html, or similar patterns to inform victims about the encryption and
provide payment instructions.
Runbook: |
1. Query CloudTrail for all S3 API calls by the userIdentity:arn in the 24 hours before and after this alert, focusing on DeleteObject, DeleteObjects, PutBucketEncryption, DeleteBucketEncryption, and PutBucketVersioning events from the same requestParameters:bucketName
2. Check if the sourceIPAddress has accessed this S3 bucket in the past 90 days and compare the volume of operations to establish if this is anomalous activity
3. Find other alerts with this rule ID or any S3 deletion/encryption rules for the same bucket or user in the past 7 days to identify if this is part of a coordinated ransomware attack pattern
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/notification-content-structure.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisPutObjecterrorCodeis emptyerrorMessageis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | ne | PutObject | excludes:eventName field:"eventName" value:"PutObject" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
errorMessage | is_null | field:"aws::errorMessage" kind:is_null | |
eventName | eq |
| field:"aws::eventName" kind:eq value:"PutObject" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
bucketName | requestParameters.bucketName |
actor_user |
Response runbook
1. Query CloudTrail for all S3 API calls by the userIdentity:arn in the 24 hours before and after this alert, focusing on DeleteObject, DeleteObjects, PutBucketEncryption, DeleteBucketEncryption, and PutBucketVersioning events from the same requestParameters:bucketName
2. Check if the sourceIPAddress has accessed this S3 bucket in the past 90 days and compare the volume of operations to establish if this is anomalous activity
3. Find other alerts with this rule ID or any S3 deletion/encryption rules for the same bucket or user in the past 7 days to identify if this is part of a coordinated ransomware attack pattern
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"CipherSuite": "TLS_AES_128_GCM_SHA256",
"SSEApplied": "SSE_S3",
"SignatureVersion": "SigV4",
"bytesTransferredIn": 2048,
"bytesTransferredOut": 0
},
"awsRegion": "us-west-2",
"eventID": "z9y8x7w6-5432-10fe-dcba-EXAMPLE22222",
"eventName": "PutObject",
"eventSource": "s3.amazonaws.com",
"eventTime": "2025-12-03T18:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": false,
"readOnly": false,
"recipientAccountId": "111111111111",
"requestID": "EXAMPLE987654321",
"requestParameters": {
"Host": "sample-bucket-hungry-buck.s3.us-west-2.amazonaws.com",
"bucketName": "production-data",
"key": "documents/financial/HOW_TO_DECRYPT_FILES.txt"
},
"resources": [
{
"ARN": "arn:aws:s3:::sample-bucket-hungry-buck",
"accountId": "111111111111",
"type": "AWS::S3::Bucket"
},
{
"ARN": "arn:aws:s3:::sample-bucket-hungry-buck/documents/financial/HOW_TO_DECRYPT_FILES.txt",
"type": "AWS::S3::Object"
}
],
"responseElements": null,
"sourceIPAddress": "1.2.3.4",
"userAgent": "aws-sdk-python/1.26.0",
"userIdentity": {
"accessKeyId": "AKIA-MOCKACCESSKEYID-1",
"accountId": "111111111111",
"arn": "arn:aws:iam::111111111111:user/compromised-user",
"principalId": "AIDA-MOCKIAMUSERID-1",
"type": "IAMUser",
"userName": "compromised-user"
}
}
AWS S3 Security Control Disabling
#Detects the disabling of 2 or more distinct S3 security controls (logging, versioning, and MFA delete protection) on the same bucket by the same actor within a short timeframe. Threshold is set to 2 rather than 3 because versioning and MFA delete can be disabled together in a single PutBucketVersioning API call, which produces only one unique value — making a threshold of 3 structurally unreachable in that scenario. This pattern is a strong indicator of preparation for ransomware or data destruction attacks, as attackers typically disable recovery mechanisms before encrypting or deleting data.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment | |
| Impact |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS S3 Bucket Server Access Logging Disabled (Elastic)
- AWS S3 Bucket Versioning Disable (Sigma)
- AWS S3 Data Management Tampering (Sigma)
- AWS S3 Object Versioning Suspended (Elastic)
- S3 Bucket Logging Disabled (Panther)
- S3 Bucket Versioning Suspended (Panther)
- S3 MFA Delete Disabled (Panther)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
if not aws_cloudtrail_success(event):
return False
if event.get("eventSource") != "s3.amazonaws.com":
return False
event_name = event.get("eventName")
if event_name == "PutBucketLogging":
return event.deep_get("requestParameters", "logging") == ""
if event_name == "PutBucketVersioning":
status = event.deep_get(
"requestParameters", "VersioningConfiguration", "Status", default=""
)
mfa_delete = event.deep_get(
"requestParameters", "VersioningConfiguration", "MfaDelete", default=""
)
return status in ("Suspended", "Disabled") or mfa_delete == "Disabled"
return False
def unique(event):
if event.get("eventName") == "PutBucketLogging":
return "logging_disabled"
# PutBucketVersioning — both versioning and MFA delete can be disabled in a single API call.
# When that happens this event contributes only one unique value, capping the maximum
# distinct values at 2 (this + logging_disabled). Threshold is set to 2 so the rule
# fires correctly even when all three controls are disabled via just two API calls.
status = event.deep_get("requestParameters", "VersioningConfiguration", "Status", default="")
mfa_delete = event.deep_get(
"requestParameters", "VersioningConfiguration", "MfaDelete", default=""
)
versioning_disabled = status in ("Suspended", "Disabled")
mfa_disabled = mfa_delete == "Disabled"
if versioning_disabled and mfa_disabled:
return "versioning_and_mfa_disabled"
if versioning_disabled:
return "versioning_suspended"
return "mfa_delete_disabled"
def dedup(event):
bucket = event.deep_get("requestParameters", "bucketName", default="UNKNOWN_BUCKET")
actor = event.deep_get("userIdentity", "arn", default="UNKNOWN_ACTOR")
return f"{bucket}:{actor}"
def title(event):
bucket = event.deep_get("requestParameters", "bucketName", default="UNKNOWN_BUCKET")
actor = event.udm("actor_user")
return f"[AWS.S3] Multiple security controls disabled on bucket [{bucket}] by [{actor}]"
def alert_context(event):
context = aws_rule_context(event)
context["bucketName"] = event.deep_get(
"requestParameters", "bucketName", default="UNKNOWN_BUCKET"
)
return context
Rule specification
AnalysisType: rule
RuleID: "AWS.S3.SecurityControlDisabling"
DisplayName: "AWS S3 Security Control Disabling"
Filename: aws_s3_security_control_disabling.py
Enabled: true
Status: Experimental
LogTypes:
- AWS.CloudTrail
Severity: High
Threshold: 2
DedupPeriodMinutes: 90
Description: >
Detects the disabling of 2 or more distinct S3 security controls (logging, versioning, and MFA
delete protection) on the same bucket by the same actor within a short timeframe. Threshold is
set to 2 rather than 3 because versioning and MFA delete can be disabled together in a single
PutBucketVersioning API call, which produces only one unique value — making a threshold of 3
structurally unreachable in that scenario. This pattern is a strong indicator of preparation for
ransomware or data destruction attacks, as attackers typically disable recovery mechanisms before
encrypting or deleting data.
Runbook: |
1. Query CloudTrail for all S3 API calls by the actor ARN on the affected bucket in the 24 hours before and after the bucket logging was disabled to identify if this is part of a larger attack pattern
2. Check for subsequent suspicious activities on the same bucket including DeleteObject, DeleteObjects, PutBucketEncryption, or GetObject events in the 6 hours after all three security controls were disabled
3. Find all other S3 buckets where this actor ARN has disabled logging, versioning, or MFA delete in the past 7 days to determine if this is a widespread attack
Reference: https://rhinosecuritylabs.com/aws/s3-ransomware-part-1-attack-vector/
Tags:
- AWS
- S3
- Ransomware
Reports:
MITRE ATT&CK:
- TA0005:T1562 # Defense Evasion: Impair Defenses
- TA0040:T1485 # Impact: Data Destruction
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceiss3.amazonaws.comany of:
all of:
eventNameisPutBucketLoggingrequestParameters.loggingis""
all of:
eventNameis notPutBucketLoggingeventNameisPutBucketVersioningany of:
requestParameters.VersioningConfiguration.Statusis one ofSuspended,DisabledrequestParameters.VersioningConfiguration.MfaDeleteisDisabled
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq |
eventName | ne |
| field:"aws::eventName" kind:ne value:"PutBucketLogging" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"s3.amazonaws.com" |
requestParameters.VersioningConfiguration.MfaDelete | eq |
| field:"requestParameters.VersioningConfiguration.MfaDelete" kind:eq value:"Disabled" |
requestParameters.VersioningConfiguration.Status | in |
| field:"requestParameters.VersioningConfiguration.Status" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
bucketName | requestParameters.bucketName |
actor_user |
Response runbook
1. Query CloudTrail for all S3 API calls by the actor ARN on the affected bucket in the 24 hours before and after the bucket logging was disabled to identify if this is part of a larger attack pattern
2. Check for subsequent suspicious activities on the same bucket including DeleteObject, DeleteObjects, PutBucketEncryption, or GetObject events in the 6 hours after all three security controls were disabled
3. Find all other S3 buckets where this actor ARN has disabled logging, versioning, or MFA delete in the past 7 days to determine if this is a widespread attack
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "12345678-1234-1234-1234-111111111111",
"eventName": "PutBucketLogging",
"eventSource": "s3.amazonaws.com",
"eventTime": "2024-01-15T10:45:23Z",
"eventType": "AwsApiCall",
"eventVersion": "1.11",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "111111111111",
"requestID": "ABC123DEF456",
"requestParameters": {
"BucketLoggingStatus": {
"xmlns": "http://s3.amazonaws.com/doc/2006-03-01/"
},
"bucketName": "critical-data-bucket",
"logging": ""
},
"responseElements": null,
"sourceIPAddress": "1.2.3.4",
"userAgent": "aws-cli/2.13.0 Python/3.11.4 Linux/5.10.0-1234-aws exe/x86_64.ubuntu.22",
"userIdentity": {
"accessKeyId": "ASIA-MOCKACCESSKEYID-1",
"accountId": "111111111111",
"arn": "arn:aws:sts::111111111111:assumed-role/sample-role-dreamy-yonath/user_name",
"principalId": "AAAAAAAAAAAAAAAAAAAAA:user_name",
"sessionContext": {
"attributes": {
"creationDate": "2024-01-15T10:30:00Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111111111111",
"arn": "arn:aws:iam::111111111111:role/sample-role-dreamy-yonath",
"principalId": "AAAAAAAAAAAAAAAAAAAAA",
"type": "Role",
"userName": "AdminRole"
}
},
"type": "AssumedRole"
}
}
AWS S3 Security Controls Disabled
#Detects the disabling of multiple S3 security controls (logging, versioning and MFA delete protection) on the same bucket within a short timeframe. This pattern is a strong indicator of preparation for ransomware or data destruction attacks, as attackers typically disable recovery mechanisms before encrypting or deleting data. Alerting on this activity enables early intervention before actual data loss occurs.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Rule specification
AnalysisType: correlation_rule
RuleID: "AWS.S3.Disable.Security.Controls"
DisplayName: "AWS S3 Security Controls Disabled"
Enabled: false
Status: Deprecated
Severity: High
Description: >
Detects the disabling of multiple S3 security controls (logging, versioning and MFA delete protection)
on the same bucket within a short timeframe. This pattern is a strong indicator of preparation
for ransomware or data destruction attacks, as attackers typically disable recovery mechanisms
before encrypting or deleting data. Alerting on this activity enables early intervention before
actual data loss occurs.
Runbook: |
1. Query CloudTrail for all S3 API calls by the actor ARN on the affected bucket in the 24 hours before and after the bucket logging was disabled to identify if this is part of a larger attack pattern
2. Check for subsequent suspicious activities on the same bucket including DeleteObject, DeleteObjects, PutBucketEncryption, or GetObject events in the 6 hours after all three security controls were disabled
3. Find all other S3 buckets where this actor ARN has disabled logging, versioning, or MFA delete in the past 7 days to determine if this is a widespread attack
Reference: https://rhinosecuritylabs.com/aws/s3-ransomware-part-1-attack-vector/
Tags:
- AWS
- S3
- Ransomware
Reports:
MITRE ATT&CK:
- TA0005:T1562 # Defense Evasion: Impair Defenses
Detection:
- Group:
- ID: Disable S3 Logging
RuleID: AWS.S3.DisableBucketLogging
- ID: Versioning Suspended
RuleID: AWS.S3.SuspendVersioning
- ID: MFA Delete Disabled
RuleID: AWS.S3.DisableMfaDelete
MatchCriteria:
field_name:
- GroupID: Disable S3 Logging
Match: p_alert_context.bucketName
- GroupID: Versioning Suspended
Match: p_alert_context.bucketName
- GroupID: MFA Delete Disabled
Match: p_alert_context.bucketName
LookbackWindowMinutes: 1800
Schedule:
RateMinutes: 1440
TimeoutMinutes: 10
Stages and Predicates
Fires when the steps below all occur within 30h, correlated by p_alert_context.bucketName. Each step needs one match unless a higher minimum is shown.
Stage 1: step Disable S3 Logging
References detection S3 Bucket Logging Disabled.
Stage 2: step Versioning Suspended
References detection S3 Bucket Versioning Suspended.
Stage 3: step MFA Delete Disabled
References detection S3 MFA Delete Disabled.
Response runbook
1. Query CloudTrail for all S3 API calls by the actor ARN on the affected bucket in the 24 hours before and after the bucket logging was disabled to identify if this is part of a larger attack pattern
2. Check for subsequent suspicious activities on the same bucket including DeleteObject, DeleteObjects, PutBucketEncryption, or GetObject events in the 6 hours after all three security controls were disabled
3. Find all other S3 buckets where this actor ARN has disabled logging, versioning, or MFA delete in the past 7 days to determine if this is a widespread attack
AWS S3 Unauthenticated Access
#Checks for S3 access attempts where the requester is not an authenticated AWS user.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
from panther_aws_helpers import aws_rule_context
# A list of buckets where authenticated access is expected
AUTH_BUCKETS = {"example-bucket"}
def rule(event):
return event.get("bucket") in AUTH_BUCKETS and not event.get("requester")
def title(event):
return f"Unauthenticated access to S3 Bucket [{event.get('bucket', '<UNKNOWN_BUCKET')}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_s3_unauthenticated_access.py
RuleID: "AWS.S3.ServerAccess.Unauthenticated"
DisplayName: "AWS S3 Unauthenticated Access"
Enabled: false
LogTypes:
- AWS.S3ServerAccess
Tags:
- AWS
- Configuration Required
- Security Control
- Collection:Data From Cloud Storage Object
Reports:
MITRE ATT&CK:
- TA0009:T1530
Severity: Low
Description: >
Checks for S3 access attempts where the requester is not an authenticated AWS user.
Runbook: >
If unauthenticated S3 access is not expected for this bucket, update its access policies.
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-auth-workflow-bucket-operation.html
SummaryAttributes:
- bucket
- key
- requester
Stages and Predicates
Fires on AWS.S3ServerAccess events when all of the conditions below hold.
Condition
bucketis one ofexample-bucketrequesteris empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
bucket | in |
| field:"bucket" kind:in value:"example-bucket" |
requester | is_null | field:"requester" kind:is_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
bucket |
Response runbook
If unauthenticated S3 access is not expected for this bucket, update its access policies.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"bucket": "example-bucket"
}
AWS S3 Unknown Requester
#Validates that proper IAM entities are accessing sensitive data buckets.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Collection |
Detection logic
from fnmatch import fnmatch
from panther_aws_helpers import aws_rule_context
# pylint: disable=line-too-long
BUCKET_ROLE_MAPPING = {
"panther-bootstrap-processeddata-*": [
"arn:aws:sts::*:assumed-role/panther-cloud-security-EventProcessorFunctionRole-*/panther-aws-event-processor",
"arn:aws:sts::*:assumed-role/panther-log-analysis-AthenaApiFunctionRole-*/panther-athena-api",
"arn:aws:sts::*:assumed-role/panther-log-analysis-RulesEngineFunctionRole-*/panther-rules-engine",
"arn:aws:sts::*:assumed-role/panther-snowflake-logprocessing-role-*/snowflake",
"arn:aws:sts::*:assumed-role/panther-data-replication-role-*/s3-replication",
]
}
# pylint: enable=line-too-long
def _unknown_requester_access(event):
for bucket_pattern, role_patterns in BUCKET_ROLE_MAPPING.items():
if not fnmatch(event.get("bucket", ""), bucket_pattern):
continue
if not any(
(fnmatch(event.get("requester", ""), role_pattern) for role_pattern in role_patterns)
):
return True
return False
def rule(event):
if event.get("errorcode"):
return False
return event.get("operation") == "REST.GET.OBJECT" and _unknown_requester_access(event)
def title(event):
return (
f"Unknown requester accessing data from S3 Bucket "
f"[{event.get('bucket', '<UNKNOWN_BUCKET>')}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_s3_unknown_requester_get_object.py
RuleID: "AWS.S3.ServerAccess.UnknownRequester"
DisplayName: "AWS S3 Unknown Requester"
DedupPeriodMinutes: 60 # 1 hour
Enabled: false
LogTypes:
- AWS.S3ServerAccess
Tags:
- AWS
- Configuration Required
- Security Control
- Collection:Data From Cloud Storage Object
Reports:
Panther:
- Data Access
MITRE ATT&CK:
- TA0009:T1530
Severity: Low
Description: Validates that proper IAM entities are accessing sensitive data buckets.
Runbook: If the S3 access is not expected for this bucket, investigate the requester's other traffic.
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/walkthrough1.html
SummaryAttributes:
- bucket
- key
- operation
- userAgent
- remoteip
- requester
- p_any_aws_arns
- p_any_aws_account_ids
Stages and Predicates
Fires on AWS.S3ServerAccess events when all of the conditions below hold.
Condition
errorcodeis emptyoperationisREST.GET.OBJECT
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorcode | is_null | field:"errorcode" kind:is_null | |
operation | eq |
| field:"operation" kind:eq value:"REST.GET.OBJECT" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
bucket |
Response runbook
If the S3 access is not expected for this bucket, investigate the requester's other traffic.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"authenticationtype": "AuthHeader",
"bucket": "panther-bootstrap-processeddata-AF1341JAK",
"bucketowner": "f16a9e81a6589df1c902c86f7982fd14a88787db",
"ciphersuite": "ECDHE-RSA-AES128-SHA",
"hostheader": "cloudtrail.s3.us-east-1.amazonaws.com",
"hostid": "neRpT/AXRsS3LMBqq/wND59opwPRWWKn7F6evEhdbS99me5fyIXpVI/MMIn6ECgU1YZAqwuF8Bw=",
"httpstatus": 200,
"key": "AWSLogs/o-wwwwwwgggg/234567890123/CloudTrail-Digest/ca-central-1/2020/02/14/234567890123_CloudTrail-Digest_ca-central-1_POrgTrail_us-east-1_20200214T001007Z.json.gz",
"objectsize": 747,
"operation": "REST.GET.OBJECT",
"remoteip": "127.0.0.1",
"requester": "arn:aws:iam::123456789012:user/jim-bob",
"requestid": "101B7403B9828743",
"requesturi": "PUT /AWSLogs/o-wwwwwwgggg/234567890123/CloudTrail-Digest/ca-central-1/2020/02/14/234567890123_CloudTrail-Digest_ca-central-1_POrgTrail_us-east-1_20200214T001007Z.json.gz HTTP/1.1",
"signatureversion": "SigV4",
"time": "2020-02-14 00:53:48.000000000",
"tlsVersion": "TLSv1.2",
"totaltime": 110,
"turnaroundtime": 20,
"useragent": "aws-internal/3 aws-sdk-java/1.11.714 Linux/4.9.184-0.1.ac.235.83.329.metal1.x86_64 OpenJDK_64-Bit_Server_VM/25.242-b08 java/1.8.0_242 vendor/Oracle_Corporation"
}
AWS SAML Activity
#Identifies when SAML activity has occurred in AWS. An adversary could gain backdoor access via SAML.
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS IAM SAML Provider Created (Elastic)
- AWS IAM SAML Provider Updated (Elastic)
- AWS SAML Provider Deletion Activity (Sigma)
- AWS Suspicious SAML Activity (Sigma)
Detection logic
from panther_aws_helpers import aws_rule_context
SAML_ACTIONS = ["UpdateSAMLProvider", "CreateSAMLProvider", "DeleteSAMLProvider"]
def rule(event):
# Allow AWSSSO to manage
if event.deep_get("userIdentity", "arn", default="").endswith(
":assumed-role/AWSServiceRoleForSSO/AWS-SSO"
):
return False
# Don't alert on errors such as EntityAlreadyExistsException and NoSuchEntity
if event.get("errorCode"):
return False
return (
event.get("eventSource") == "iam.amazonaws.com" and event.get("eventName") in SAML_ACTIONS
)
def title(event):
return (
f"[{event.deep_get('userIdentity','arn')}] "
f"performed [{event.get('eventName')}] "
f"in account [{event.get('recipientAccountId')}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: Identifies when SAML activity has occurred in AWS. An adversary could gain backdoor access via SAML.
DisplayName: "AWS SAML Activity"
Enabled: true
Filename: aws_saml_activity.py
Reference: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-managing-saml-idp-console.html
Severity: Medium
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.Suspicious.SAML.Activity"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
userIdentity.arndoes not end with:assumed-role/AWSServiceRoleForSSO/AWS-SSOerrorCodeis emptyeventSourceisiam.amazonaws.comeventNameis one ofUpdateSAMLProvider,CreateSAMLProvider,DeleteSAMLProvider
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
userIdentity.arn | ends_with | :assumed-role/AWSServiceRoleForSSO/AWS-SSO | excludes:userIdentity.arn field:"userIdentity.arn" value:":assumed-role/AWSServiceRoleForSSO/AWS-SSO" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"iam.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "EID12345",
"eventName": "CreateSAMLProvider",
"eventSource": "iam.amazonaws.com",
"eventTime": "2021-10-14 21:25:20",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"recipientAccountId": "0123456789",
"requestID": "ABC1234",
"sourceIPAddress": "1.2.3.4",
"userAgent": "cloudformation.amazonaws.com",
"userIdentity": {
"accessKeyId": "ABCDEFGHIJK",
"accountId": "0123456789",
"arn": "arn:aws:sts::0123456789:assumed-role/role/account",
"invokedBy": "cloudformation.amazonaws.com",
"principalId": "0123456789:AWSCloudFormation",
"sessionContext": {
"attributes": {
"creationDate": "2021-10-14T21:25:20Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "0123456789",
"arn": "arn:aws:iam::0123456789:role/ServiceRole",
"principalId": "ABCDEFGI0123",
"type": "Role",
"userName": "ServiceRole"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Secrets Manager Batch Retrieve Secrets
#An attacker attempted to retrieve a high number of Secrets Manager secrets by batch, through secretsmanager:BatchGetSecretValue (released Novemeber 2023). An attacker may attempt to retrieve a high number of secrets by batch, to avoid detection and generate fewer calls. Note that the batch size is limited to 20 secrets.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventName") == "BatchGetSecretValue":
return True
return False
def title(event):
user = event.udm("actor_user")
return (
f"[{user}] attempted to batch retrieve a large number of secrets from AWS Secrets Manager"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_secretsmanager_retrieve_secrets_batch.py
RuleID: "AWS.SecretsManager.BatchRetrieveSecrets"
DisplayName: "AWS Secrets Manager Batch Retrieve Secrets"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Credential Access
- Stratus Red Team
Status: Experimental
Reports:
MITRE ATT&CK:
- TA0006:T1552 # Credentials from Password Stores
Stratus Red Team:
- aws.credential-access.secretsmanager-batch-retrieve-secrets
Severity: Info
Description: >
An attacker attempted to retrieve a high number of Secrets Manager secrets by batch, through secretsmanager:BatchGetSecretValue (released Novemeber 2023).
An attacker may attempt to retrieve a high number of secrets by batch, to avoid detection and generate fewer calls. Note that the batch size is limited to 20 secrets.
Runbook: https://aws.amazon.com/blogs/security/how-to-use-the-batchgetsecretsvalue-api-to-improve-your-client-side-applications-with-aws-secrets-manager/
Reference: https://stratus-red-team.cloud/attack-techniques/AWS/aws.credential-access.secretsmanager-batch-retrieve-secrets/
Threshold: 5
DedupPeriodMinutes: 1440
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisBatchGetSecretValue
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"BatchGetSecretValue" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"eventName": "BatchGetSecretValue",
"eventSource": "secretsmanager.amazonaws.com",
"eventType": "AwsApiCall",
"managementEvent": true,
"readOnly": true,
"recipientAccountId": "012345678901",
"requestParameters": {
"filters": [
{
"key": "tag-key",
"values": [
"StratusRedTeam"
]
}
]
},
"responseElements": null
}
AWS Secrets Manager Batch Retrieve Secrets Catch-All
#An attacker attempted to retrieve a high number of Secrets Manager secrets by batch, through secretsmanager:BatchGetSecretValue (released Novemeber 2023). An attacker may attempt to retrieve a high number of secrets by batch, to avoid detection and generate fewer calls. Note that the batch size is limited to 20 secrets. Although BatchGetSecretValue requires a list of secret IDs or a filter, an attacker may use a catch-all filter to retrieve all secrets by batch. This rule identifies BatchGetSecretValue events with a catch-all filter.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if event.get("eventName") != "BatchGetSecretValue":
return False
filters = event.deep_get("requestParameters", "filters", default=[])
for filt in filters:
if filt.get("key") != "tag-key":
return False
if any(not value.startswith("!") for value in filt.get("values")):
return False
return True
def title(event):
user = event.udm("actor_user")
return (
f"[{user}] attempted to batch retrieve secrets from "
"AWS Secrets Manager with a catch-all filter"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_secretsmanager_retrieve_secrets_catchall.py
RuleID: "AWS.SecretsManager.BatchRetrieveSecretsCatchAll"
DisplayName: "AWS Secrets Manager Batch Retrieve Secrets Catch-All"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Credential Access
- Stratus Red Team
Status: Experimental
Reports:
MITRE ATT&CK:
- TA0006:T1552 # Credentials from Password Stores
Severity: Info
Description: >
An attacker attempted to retrieve a high number of Secrets Manager secrets by batch, through secretsmanager:BatchGetSecretValue (released Novemeber 2023).
An attacker may attempt to retrieve a high number of secrets by batch, to avoid detection and generate fewer calls. Note that the batch size is limited to 20 secrets.
Although BatchGetSecretValue requires a list of secret IDs or a filter, an attacker may use a catch-all filter to retrieve all secrets by batch.
This rule identifies BatchGetSecretValue events with a catch-all filter.
Runbook: https://aws.amazon.com/blogs/security/how-to-use-the-batchgetsecretsvalue-api-to-improve-your-client-side-applications-with-aws-secrets-manager/
Reference: https://stratus-red-team.cloud/attack-techniques/AWS/aws.credential-access.secretsmanager-batch-retrieve-secrets/
Threshold: 1
DedupPeriodMinutes: 1440
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisBatchGetSecretValue
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"BatchGetSecretValue" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"eventName": "BatchGetSecretValue",
"eventSource": "secretsmanager.amazonaws.com",
"eventType": "AwsApiCall",
"managementEvent": true,
"readOnly": true,
"recipientAccountId": "012345678901",
"requestParameters": {
"filters": [
{
"key": "tag-key",
"values": [
"!tagKeyThatWillNeverExist"
]
}
]
},
"responseElements": null
}
AWS Secrets Manager Retrieve Secrets Multi-Region
#An attacker attempted to retrieve a high number of Secrets Manager secrets by batch, through secretsmanager:BatchGetSecretValue (released Novemeber 2023). An attacker may attempt to retrieve a high number of secrets by batch, to avoid detection and generate fewer calls. Note that the batch size is limited to 20 secrets. This rule identifies BatchGetSecretValue events for multiple regions in a short period of time.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return event.get("eventName") == "BatchGetSecretValue"
def unique(event):
return event.get("awsRegion", "")
def title(event):
user = event.udm("actor_user")
return f"[{user}] attempted to retrieve secrets from AWS Secrets Manager in multiple regions"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_secretsmanager_retrieve_secrets_multiregion.py
RuleID: "AWS.SecretsManager.RetrieveSecretsMultiRegion"
DisplayName: "AWS Secrets Manager Retrieve Secrets Multi-Region"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Credential Access
- Stratus Red Team
Reports:
MITRE ATT&CK:
- TA0006:T1552 # Credentials from Password Stores
Severity: Info
Description: >
An attacker attempted to retrieve a high number of Secrets Manager secrets by batch, through secretsmanager:BatchGetSecretValue (released Novemeber 2023).
An attacker may attempt to retrieve a high number of secrets by batch, to avoid detection and generate fewer calls. Note that the batch size is limited to 20 secrets.
This rule identifies BatchGetSecretValue events for multiple regions in a short period of time.
Runbook: https://aws.amazon.com/blogs/security/how-to-use-the-batchgetsecretsvalue-api-to-improve-your-client-side-applications-with-aws-secrets-manager/
Reference: https://stratus-red-team.cloud/attack-techniques/AWS/aws.credential-access.secretsmanager-batch-retrieve-secrets/
Threshold: 5
DedupPeriodMinutes: 10
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisBatchGetSecretValue
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"BatchGetSecretValue" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventName": "BatchGetSecretValue",
"eventSource": "secretsmanager.amazonaws.com",
"eventType": "AwsApiCall",
"managementEvent": true,
"readOnly": true,
"recipientAccountId": "012345678901",
"requestParameters": {
"filters": [
{
"key": "tag-key",
"values": [
"!tagKeyThatWillNeverExist"
]
}
]
},
"responseElements": null
}
AWS Security Group - Only DMZ Publicly Accessible
#This policy validates that only Security Groups designated as DMZs allow inbound traffic from public IP space. This helps ensure no traffic is bypassing the DMZ.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
import json
from ipaddress import ip_network
from unittest.mock import MagicMock
# NOTE: Make sure to adjust DMZ_TAGS
DMZ_TAGS = [
# ["environment", "dmz"]
]
# Defaults to False to assume something is not a DMZ if it is not tagged
def is_dmz_tags(resource, dmz_tags):
"""This function determines whether a given resource is tagged as existing in a DMZ."""
if resource["Tags"] is None:
return False
for key, value in dmz_tags:
if resource["Tags"].get(key) == value:
return True
return False
def policy(resource):
# If this security group allows no inbound connections, it is secure
if resource["IpPermissions"] is None:
return True
# DMZ security groups can have inbound permissions from the internet
global DMZ_TAGS # pylint: disable=global-statement
if isinstance(DMZ_TAGS, MagicMock):
DMZ_TAGS = {tuple(kv) for kv in json.loads(DMZ_TAGS())} # pylint: disable=not-callable
if is_dmz_tags(resource, DMZ_TAGS):
return True
for permission in resource["IpPermissions"]:
# Check if any traffic is allowed from public IP space
for ip_range in permission["IpRanges"] or []:
if ip_range["CidrIp"] == "0.0.0.0/0" or not ip_network(ip_range["CidrIp"]).is_private:
return False
for ip_range in permission["Ipv6Ranges"] or []:
if ip_range["CidrIpv6"] == "::/0" or not ip_network(ip_range["CidrIpv6"]).is_private:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_only_dmz_security_groups_publicly_accessible.py
PolicyID: "AWS.SecurityGroup.OnlyDMZPubliclyAccessible"
DisplayName: "AWS Security Group - Only DMZ Publicly Accessible"
Enabled: false
ResourceTypes:
- AWS.EC2.SecurityGroup
Tags:
- AWS
- PCI
- Initial Access:Exploit Public-Facing Application
Reports:
PCI:
- 1.3.1
- 1.1.4
MITRE ATT&CK:
- TA0001:T1190
Severity: Medium
Description: >
This policy validates that only Security Groups designated as DMZs allow inbound traffic from public IP space. This helps ensure no traffic is bypassing the DMZ.
Runbook: >
Remove the IP permissions in non-DMZ Security Groups that are allowing inbound traffic from public IP space.
Reference: https://en.wikipedia.org/wiki/DMZ_(computing)
Stages and Predicates
Flags AWS.EC2.SecurityGroup resources when all of the conditions below hold.
Condition
IpPermissionsis presentTagsis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Tags | is_not_null | excludes:Tags | |
IpPermissions | is_null | excludes:IpPermissions |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
IpPermissions | is_not_null | field:"IpPermissions" kind:is_not_null | |
Tags | is_null | field:"Tags" kind:is_null |
Response runbook
Remove the IP permissions in non-DMZ Security Groups that are allowing inbound traffic from public IP space.
AWS Security Group Administrative Ingress
#This policy validates that AWS Security Groups don't allow unrestricted inbound traffic on port 3389 or 22, ports commonly used for the remote access protocols RDP and SSH respectively.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
RESTRICTED_PORTS = [22, 3389]
# Returns true if at least one of the ports in check_ports are between from_port and to_port
# Returns true if from_port or to_port is None as that indicates an unrestricted range of ports
def port_checker(check_ports, from_port, to_port):
if from_port is None and to_port is None:
return True
for port in check_ports:
if from_port <= port <= to_port:
return True
return False
def policy(resource):
if resource["IpPermissions"] is None:
return True
for permission in resource["IpPermissions"]:
src_open = False
for ip_range in permission["IpRanges"] or []:
if ip_range["CidrIp"] == "0.0.0.0/0":
src_open = True
break
for ipv6_range in permission["Ipv6Ranges"] or []:
if src_open or ipv6_range["CidrIpv6"] == "::/0":
src_open = True
break
if src_open and port_checker(
RESTRICTED_PORTS, permission["FromPort"], permission["ToPort"]
):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_security_group_administrative_ingress.py
PolicyID: "AWS.SecurityGroup.AdministrativeIngress"
DisplayName: "AWS Security Group Administrative Ingress"
Enabled: true
ResourceTypes:
- AWS.EC2.SecurityGroup
Tags:
- AWS
- Security Control
- Initial Access:Exploit Public-Facing Application
Reports:
CIS:
- 4.1
- 4.2
MITRE ATT&CK:
- TA0001:T1190
Severity: High
Description: >
This policy validates that AWS Security Groups don't allow unrestricted inbound traffic on
port 3389 or 22, ports commonly used for the remote access protocols RDP and SSH respectively.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-securitygroup-restricts-ingress-on-administrative-ports
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html
Stages and Predicates
Flags AWS.EC2.SecurityGroup resources when the condition below holds.
Condition
IpPermissionsis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
IpPermissions | is_null | excludes:IpPermissions |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
IpPermissions | is_not_null | field:"IpPermissions" kind:is_not_null |
Response runbook
AWS Security Group Restricts Access To CDE
#This policy validates that are considered part of the PCI CDE do not allow any access from public IP space.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
from ipaddress import ip_network
def policy(resource):
for permission in resource["IpPermissions"] or []:
# Check if any traffic is allowed from public IP space
for ip_range in permission["IpRanges"] or []:
if ip_range["CidrIp"] == "0.0.0.0/0" or not ip_network(ip_range["CidrIp"]).is_private:
return False
for ip_range in permission["Ipv6Ranges"] or []:
if ip_range["CidrIpv6"] == "::/0" or not ip_network(ip_range["CidrIpv6"]).is_private:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_security_group_restricts_access_to_cde.py
PolicyID: "AWS.SecurityGroup.RestrictsAccessToCDE"
DisplayName: "AWS Security Group Restricts Access To CDE"
Enabled: false
ResourceTypes:
- AWS.EC2.SecurityGroup
Tags:
- AWS
- PCI
- Initial Access:Exploit Public-Facing Application
Reports:
PCI:
- 1.3.2
- 1.3.5
MITRE ATT&CK:
- TA0001:T1190
Severity: Medium
Description: >
This policy validates that are considered part of the PCI CDE do not allow any access from public IP space.
Runbook: >
Remove any IP permissions allowing inbound traffic from Public IP space to the CDE.
Reference: https://docs.aws.amazon.com/cli/latest/userguide/cli-services-ec2-sg.html
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
Remove any IP permissions allowing inbound traffic from Public IP space to the CDE.
AWS Security Group Restricts Inbound Traffic
#This policy validates that Security Groups have some restrictions on inbound traffic.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
# This is a generic policy that checks inbound permissions on a Security Group.
# You may wish to add additional logic specific to your use cases.
def policy(resource):
if resource["IpPermissions"] is None:
return True
for permission in resource["IpPermissions"]:
# Check if the permission is set to "All Ports"
if permission["FromPort"] is None or permission["ToPort"] is None:
return False
# Check if the permission is set to "All TCP" or "All UDP" ports
if permission["FromPort"] == 0 and permission["ToPort"] == 65535:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_security_group_restricts_inbound_traffic.py
PolicyID: "AWS.SecurityGroup.RestrictsInboundTraffic"
DisplayName: "AWS Security Group Restricts Inbound Traffic"
Enabled: false
ResourceTypes:
- AWS.EC2.SecurityGroup
Tags:
- AWS
- PCI
- Initial Access:Exploit Public-Facing Application
Reports:
PCI:
- 1.1.4
- 1.3.5
MITRE ATT&CK:
- TA0001:T1190
Severity: Medium
Description: >
This policy validates that Security Groups have some restrictions on inbound traffic.
Runbook: >
Add appropriate restrictions on inbound traffic via IP permissions.
Reference: https://docs.aws.amazon.com/cli/latest/userguide/cli-services-ec2-sg.html
Stages and Predicates
Flags AWS.EC2.SecurityGroup resources when the condition below holds.
Condition
IpPermissionsis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
IpPermissions | is_null | excludes:IpPermissions |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
IpPermissions | is_not_null | field:"IpPermissions" kind:is_not_null |
Response runbook
Add appropriate restrictions on inbound traffic via IP permissions.
AWS Security Group Restricts Inter-SG Traffic
#This policy validates that Security Groups have restrictions on inter Security Group traffic. Administrators may assume there is an implicit level of trust between Security Groups in the same account, but this is not always a good assumption in cases one Security Group contains far more sensitive data that another.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Lateral Movement |
Detection logic
def policy(resource):
if resource["IpPermissions"] is None:
return True
for permission in resource["IpPermissions"]:
# Only check Security Group -> Security Group permissions
if not permission["UserIdGroupPairs"]:
continue
# Check if the permission is set to "All Ports"
if permission["FromPort"] is None or permission["ToPort"] is None:
return False
# Check if the permission is set to "All TCP" or "All UDP" ports
if permission["FromPort"] == 0 and permission["ToPort"] == 65535:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_security_group_restricts_inter_security_group_traffic.py
PolicyID: "AWS.SecurityGroup.RestrictsInterSecurityGroupTraffic"
DisplayName: "AWS Security Group Restricts Inter-SG Traffic"
Enabled: false
ResourceTypes:
- AWS.EC2.SecurityGroup
Tags:
- AWS
- PCI
- Lateral Movement:Exploitation of Remote Services
Reports:
PCI:
- 1.2.1
MITRE ATT&CK:
- TA0008:T1210
Severity: Low
Description: >
This policy validates that Security Groups have restrictions on inter Security Group traffic. Administrators may assume there is an implicit level of trust between Security Groups in the same account, but this is not always a good assumption in cases one Security Group contains far more sensitive data that another.
Runbook: >
Add appropriate restrictions to the Security Group peering connection.
Reference: https://docs.aws.amazon.com/cli/latest/userguide/cli-services-ec2-sg.html
Stages and Predicates
Flags AWS.EC2.SecurityGroup resources when the condition below holds.
Condition
IpPermissionsis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
IpPermissions | is_null | excludes:IpPermissions |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
IpPermissions | is_not_null | field:"IpPermissions" kind:is_not_null |
Response runbook
Add appropriate restrictions to the Security Group peering connection.
AWS Security Group Restricts Outbound Traffic
#This policy validates that Security Groups have some restrictions on outbound traffic.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Detection logic
# This is a generic policy that checks outbound permissions on Security Groups.
# You may wish to add additional logic specific to your use case.
def policy(resource):
if resource["IpPermissionsEgress"] is None:
return True
for permission in resource["IpPermissionsEgress"]:
# Check if the permission is set to "All Ports"
if permission["FromPort"] is None or permission["ToPort"] is None:
return False
# Check if the permission is set to "All TCP" or "All UDP" ports
if permission["FromPort"] == 0 and permission["ToPort"] == 65535:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_security_group_restricts_outbound_traffic.py
PolicyID: "AWS.SecurityGroup.RestrictsOutboundTraffic"
DisplayName: "AWS Security Group Restricts Outbound Traffic"
Enabled: false
ResourceTypes:
- AWS.EC2.SecurityGroup
Tags:
- AWS
- PCI
- Exfiltration:Exfiltration Over Web Service
Reports:
PCI:
- 1.1.4
- 1.3.2
MITRE ATT&CK:
- TA0010:T1567
Severity: Low
Description: >
This policy validates that Security Groups have some restrictions on outbound traffic.
Runbook: >
Add appropriate restrictions to outbound traffic via IP permissions.
Reference: https://docs.aws.amazon.com/cli/latest/userguide/cli-services-ec2-sg.html
Stages and Predicates
Flags AWS.EC2.SecurityGroup resources when the condition below holds.
Condition
IpPermissionsEgressis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
IpPermissionsEgress | is_null | excludes:IpPermissionsEgress |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
IpPermissionsEgress | is_not_null | field:"IpPermissionsEgress" kind:is_not_null |
Response runbook
Add appropriate restrictions to outbound traffic via IP permissions.
AWS Security Group Restricts Traffic Leaving CDE
#This policy validates that there are restrictions on what type of traffic may leave Security Groups that are considered with the scope of the PCI CDE. These restrictions help ensure that cardholder data does not leave the CDE.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Detection logic
from ipaddress import ip_network
def policy(resource):
for permission in resource["IpPermissionsEgress"] or []:
# Check if any traffic can leave this security group to public IP space
for ip_range in permission["IpRanges"] or []:
if ip_range["CidrIp"] == "0.0.0.0/0" or not ip_network(ip_range["CidrIp"]).is_private:
return False
for ip_range in permission["Ipv6Ranges"] or []:
if ip_range["CidrIpv6"] == "::/0" or not ip_network(ip_range["CidrIpv6"]).is_private:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_security_group_restricts_traffic_leaving_cde.py
PolicyID: "AWS.SecurityGroup.RestrictsTrafficLeavingCDE"
DisplayName: "AWS Security Group Restricts Traffic Leaving CDE"
Enabled: false
ResourceTypes:
- AWS.EC2.SecurityGroup
Tags:
- AWS
- PCI
- Exfiltration:Exfiltration Over Web Service
Reports:
MITRE ATT&CK:
- TA0010:T1567
Severity: Medium
Description: >
This policy validates that there are restrictions on what type of traffic may leave Security Groups that are considered with the scope of the PCI CDE. These restrictions help ensure that cardholder data does not leave the CDE.
Runbook: >
Add appropriate restrictions to traffic leaving Security Groups considered a part of the CDE.
Reference: https://docs.aws.amazon.com/cli/latest/userguide/cli-services-ec2-sg.html
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
Add appropriate restrictions to traffic leaving Security Groups considered a part of the CDE.
AWS Security Group Tightly Restricts Inbound Traffic
#This policy validates that Security Groups have restrictive permission sets that both limit the total number of open ports, as well as limiting ports typically associated with insecure protocols.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
MAX_PORTS_PER_PERMISSION = 10
RESTRICTED_PORTS = [
21, # FTP default
22, # SSH default
23, # Telnet default
3389, # RDP default
]
def policy(resource):
if resource["IpPermissions"] is None:
return True
for permission in resource["IpPermissions"]:
# Check if the permission is set to "All Ports"
if permission["FromPort"] is None or permission["ToPort"] is None:
return False
# Check if the permission allows too many ports. Alternatively, this can be modified to sum
# open ports to have one running total.
if permission["ToPort"] - permission["FromPort"] > MAX_PORTS_PER_PERMISSION:
return False
if any(permission["FromPort"] <= port <= permission["ToPort"] for port in RESTRICTED_PORTS):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_security_group_tightly_restricts_inbound_traffic.py
PolicyID: "AWS.SecurityGroup.TightlyRestrictsInboundTraffic"
DisplayName: "AWS Security Group Tightly Restricts Inbound Traffic"
Enabled: false
ResourceTypes:
- AWS.EC2.SecurityGroup
Tags:
- AWS
- PCI
- Initial Access:Exploit Public-Facing Application
Reports:
PCI:
- 1.1.4
- 1.2.1
- 8.2.1
MITRE ATT&CK:
- TA0001:T1190
Severity: Low
Description: >
This policy validates that Security Groups have restrictive permission sets that both limit the total number of open ports, as well as limiting ports typically associated with insecure protocols.
Runbook: >
Add appropriate restrictions to inbound traffic on Security Groups via IP permissions.
Reference: https://docs.aws.amazon.com/cli/latest/userguide/cli-services-ec2-sg.html
Stages and Predicates
Flags AWS.EC2.SecurityGroup resources when the condition below holds.
Condition
IpPermissionsis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
IpPermissions | is_null | excludes:IpPermissions |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
IpPermissions | is_not_null | field:"IpPermissions" kind:is_not_null |
Response runbook
Add appropriate restrictions to inbound traffic on Security Groups via IP permissions.
AWS Security Group Tightly Restricts Outbound Traffic
#This policy validates that Security Groups have restrictive controls on outbound traffic.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Detection logic
MAX_PORTS_PER_PERMISSION = 10
RESTRICTED_PORTS = [
21, # FTP default
22, # SSH default
23, # Telnet default
3389, # RDP default
]
def policy(resource):
if resource["IpPermissionsEgress"] is None:
return True
for permission in resource["IpPermissionsEgress"]:
# Check if the permission is set to "All Ports"
if permission["FromPort"] is None or permission["ToPort"] is None:
return False
# Check if the permission allows too many ports
if permission["ToPort"] - permission["FromPort"] > MAX_PORTS_PER_PERMISSION:
return False
# Check if the permission allows too many ports. Alternatively, this can be modified to sum
# open ports to have one running total.
if permission["ToPort"] - permission["FromPort"] > MAX_PORTS_PER_PERMISSION:
return False
if any(permission["FromPort"] <= port <= permission["ToPort"] for port in RESTRICTED_PORTS):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_security_group_tightly_restricts_outbound_traffic.py
PolicyID: "AWS.SecurityGroup.TightlyRestrictsOutboundTraffic"
DisplayName: "AWS Security Group Tightly Restricts Outbound Traffic"
Enabled: false
ResourceTypes:
- AWS.EC2.SecurityGroup
Tags:
- AWS
- PCI
- Exfiltration:Exfiltration Over Web Service
Reports:
PCI:
- 1.1.4
MITRE ATT&CK:
- TA0010:T1567
Severity: Low
Description: >
This policy validates that Security Groups have restrictive controls on outbound traffic.
Runbook: >
Add appropriate restrictions to outbound traffic on Security Groups via IP permissions
Reference: https://docs.aws.amazon.com/cli/latest/userguide/cli-services-ec2-sg.html
Stages and Predicates
Flags AWS.EC2.SecurityGroup resources when the condition below holds.
Condition
IpPermissionsEgressis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
IpPermissionsEgress | is_null | excludes:IpPermissionsEgress |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
IpPermissionsEgress | is_not_null | field:"IpPermissionsEgress" kind:is_not_null |
Response runbook
Add appropriate restrictions to outbound traffic on Security Groups via IP permissions
AWS SecurityHub Finding Evasion
#Detections modification of findings in SecurityHub
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS SecurityHub Findings Evasion (Sigma)
Detection logic
from panther_aws_helpers import aws_rule_context
EVASION_OPERATIONS = ["BatchUpdateFindings", "DeleteInsight", "UpdateFindings", "UpdateInsight"]
def rule(event):
if (
event.get("eventSource", "") == "securityhub.amazonaws.com"
and event.get("eventName", "") in EVASION_OPERATIONS
):
return True
return False
def title(event):
return (
"SecurityHub Findings have been modified in account: "
f"[{event.get('recipientAccountId','')}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: Detections modification of findings in SecurityHub
DisplayName: "AWS SecurityHub Finding Evasion"
Enabled: true
Filename: aws_securityhub_finding_evasion.py
Reports:
MITRE ATT&CK:
- TA0005:T1562
Reference: https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-insights-view-take-action.html
Severity: High
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.SecurityHub.Finding.Evasion"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceissecurityhub.amazonaws.comeventNameis one ofBatchUpdateFindings,DeleteInsight,UpdateFindings,UpdateInsight
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"securityhub.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "3dabcebf-35b0-443f-a1a2-26e186ce23bf",
"eventName": "DeleteInsight",
"eventSource": "securityhub.amazonaws.com",
"eventTime": "2018-11-25T01:02:18Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "012345678901",
"requestID": "c0fffccd-f04d-11e8-93fc-ddcd14710066",
"requestParameters": {
"Filters": {},
"Name": "Test Insight",
"ResultField": "ResourceId"
},
"responseElements": {
"InsightArn": "arn:aws:securityhub:us-west-2:0123456789010:insight/custom/f4c4890b-ac6b-4c26-95f9-e62cc46f3055"
},
"sourceIPAddress": "205.251.233.179",
"userAgent": "aws-cli/1.11.76 Python/2.7.10 Darwin/17.7.0 botocore/1.5.39",
"userIdentity": {
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
"accountId": "012345678901",
"arn": "arn:aws:iam::012345678901:user/TestUser",
"principalId": "AIDAJK6U5DS22IAVUI7BW",
"type": "IAMUser",
"userName": "TestUser"
}
}
AWS Snapshot Made Public
#An AWS storage snapshot was made public.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- ASL AWS EC2 Snapshot Shared Externally (Splunk)
- AWS EC2 EBS Snapshot Access Removed (Elastic)
- AWS EC2 EBS Snapshot Shared or Made Public (Elastic)
- AWS EC2 Snapshot Shared Externally (Splunk)
- AWS Exfiltration via EC2 Snapshot (Splunk)
Detection logic
from collections.abc import Mapping
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_base_helpers import deep_get
IS_SINGLE_USER_SHARE = False # Used to adjust severity
def rule(event):
if not aws_cloudtrail_success(event):
return False
# EC2 Volume snapshot made public
if event.get("eventName") == "ModifySnapshotAttribute":
parameters = event.get("requestParameters", {})
if parameters.get("attributeType") != "CREATE_VOLUME_PERMISSION":
return False
items = deep_get(parameters, "createVolumePermission", "add", "items", default=[])
for item in items:
if not isinstance(item, (Mapping, dict)):
continue
if item.get("userId") or item.get("group") == "all":
global IS_SINGLE_USER_SHARE # pylint: disable=global-statement
IS_SINGLE_USER_SHARE = "userId" in item # Used for dynamic severity
return True
return False
return False
def severity(_):
# Set severity to INFO if only shared with a single user
if IS_SINGLE_USER_SHARE:
return "INFO"
return "DEFAULT"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_snapshot_made_public.py
RuleID: "AWS.CloudTrail.SnapshotMadePublic"
DisplayName: "AWS Snapshot Made Public"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Medium
Reports:
MITRE ATT&CK:
- TA0010:T1537
Stratus Red Team:
- aws.exfiltration.ec2-share-ebs-snapshot
Description: An AWS storage snapshot was made public.
Reference:
https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-modifying-snapshot-permissions.html
Runbook: Adjust the snapshot configuration so that it is no longer public.
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Tags:
- AWS
- Exfiltration:Transfer Data to Cloud Account
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisModifySnapshotAttributerequestParameters.attributeTypeisCREATE_VOLUME_PERMISSION
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"ModifySnapshotAttribute" |
requestParameters.attributeType | eq |
| field:"requestParameters.attributeType" kind:eq value:"CREATE_VOLUME_PERMISSION" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Adjust the snapshot configuration so that it is no longer public.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1111",
"eventName": "ModifySnapshotAttribute",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1111",
"requestParameters": {
"attributeType": "CREATE_VOLUME_PERMISSION",
"createVolumePermission": {
"add": {
"items": [
{
"group": "all"
}
]
}
},
"snapshotId": "snap-1111"
},
"responseElements": {
"_return": true,
"requestId": "1111"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla/2.0 (compatible; NEWT ActiveX; Win32)",
"userIdentity": {
"accessKeyId": "1111",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/example-role/example-user",
"principalId": "1111",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/example-role",
"principalId": "1111",
"type": "Role",
"userName": "example-role"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS Software Discovery
#A user is obtaining a list of security software, configurations, defensive tools, and sensors that are in AWS.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
DISCOVERY_EVENTS = [
"ListDocuments",
"ListMembers",
"DescribeProducts",
"DescribeStandards",
"DescribeStandardsControls",
"DescribeInstanceInformation",
"DescribeSecurityGroups",
"DescribeSecurityGroupRules",
"DescribeSecurityGroupReferences",
"DescribeSubnets",
"DescribeHub",
"ListFirewalls",
"ListRuleGroups",
"ListFirewallPolicies",
"DescribeFirewall",
"DescribeFirewallPolicy",
"DescribeLoggingConfiguration",
"DescribeResourcePolicy",
"DescribeRuleGroup",
]
def rule(event):
return event.get("eventName") in DISCOVERY_EVENTS
def title(event):
return (
f"User [{event.udm('actor_user')}] "
f"performed a [{event.get('eventName')}] "
f"action in AWS account [{event.get('recipientAccountId')}]."
)
def dedup(event):
return event.udm("actor_user")
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: A user is obtaining a list of security software, configurations, defensive tools, and sensors that are in AWS.
DisplayName: "AWS Software Discovery"
Enabled: false
Filename: aws_software_discovery.py
Reference: https://attack.mitre.org/techniques/T1518/001/
Tags:
- Configuration Required
Reports:
MITRE ATT&CK:
- TA0007:T1518
Severity: Info
CreateAlert: false
DedupPeriodMinutes: 360 # 6 hours
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.Software.Discovery"
Threshold: 50
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameis one ofListDocuments,ListMembers,DescribeProducts,DescribeStandards,DescribeStandardsControls
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "6faccad9-b6ae-4549-8e39-03430cbce2aa",
"eventName": "DescribeSecurityGroups",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2021-10-19 01:06:59",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_any_aws_account_ids": [
"123456789012"
],
"p_any_aws_arns": [
"arn:aws:iam::123456789012:role/ExampleRole-us-east-2",
"arn:aws:sts::123456789012:assumed-role/ExampleRole-us-east-2/153151351351351"
],
"p_any_ip_addresses": [
"12.34.56.78"
],
"p_event_time": "2021-10-19 01:06:59",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2021-10-19 01:11:10.412",
"p_row_id": "eabaceda7842c2b2e7a398f40cc150",
"p_source_id": "5f9f0f60-9c56-4027-b93a-8bab3019f0f1",
"p_source_label": "Hosted - Cloudtrail - XYZ",
"readOnly": true,
"recipientAccountId": "123456789012",
"requestID": "43efad11-bb40-43df-ad25-c8e7f0bfdc7a",
"requestParameters": {
"filterSet": {},
"securityGroupIdSet": {
"items": [
{
"groupId": "sg-01e29ae063f5f63a0"
}
]
},
"securityGroupSet": {}
},
"sourceIPAddress": "12.34.56.78",
"userAgent": "aws-sdk-go/1.40.21 (go1.17; linux; amd64) exec-env/AWS_Lambda_go1.x",
"userIdentity": {
"accessKeyId": "ASIA153151351351351",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/ExampleRole-us-east-2/153151351351351",
"principalId": "AROA4NOI7P47OHH3NQORX:153151351351351",
"sessionContext": {
"attributes": {
"creationDate": "2021-10-19T01:05:18Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/ExampleRole-us-east-2",
"principalId": "AROA153151351351351",
"type": "Role",
"userName": "ExampleRole-us-east-2"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS SSM Distributed Command
#Detect an attacker utilizing AWS Systems Manager (SSM) to execute commands through SendCommand on multiple EC2 instances.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Execution |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
import datetime as dt
import json
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from panther_core import PantherEvent
from panther_detection_helpers.caching import get_string_set, put_string_set
# Determine how separate instances need be commanded in order to trigger an alert
INSTANCE_THRESHOLD = 2
all_instance_ids = set()
def rule(event: PantherEvent) -> bool:
# Exclude events of the wrong type
if event.get("eventName") != "SendCommand":
return False
# Determine if this actor accessed any other params in this account
key = get_cache_key(event)
cached_ids = get_cached_instance_ids(key)
target_instance_ids = set(event.deep_get("requestParameters", "instanceIds", default=[]))
# Determine if the cache needs updating with new entries
global all_instance_ids # pylint: disable=global-statement
all_instance_ids = cached_ids | target_instance_ids
if all_instance_ids - cached_ids:
# Only set the TTL if this is the first time we're adding to the cache
# Otherwise we'll be perpetually extending the lifespan of the cached data every time we
# add more.
put_string_set(key, all_instance_ids, epoch_seconds=(3600 if not cached_ids else None))
# Check combined number of params
return len(all_instance_ids) > INSTANCE_THRESHOLD
def title(event: PantherEvent) -> str:
actor = event.udm("actor_user")
account_name = event.get("recipientAccountId")
return f"Commands distributed to many EC2 instances by [{actor}] in [{account_name}]"
def severity(event: PantherEvent) -> str:
# Demote to LOW if attempt was denied
if not aws_cloudtrail_success(event):
return "LOW"
return "DEFAULT"
def alert_context(event: PantherEvent) -> dict:
global all_instance_ids
context = aws_rule_context(event)
context.update({"instanceIds": list(all_instance_ids)})
return context
def get_cache_key(event) -> str:
"""Use the field values in the event to generate a cache key unique to this actor and
account ID."""
offset = (
dt.datetime.fromisoformat(event.get("p_event_time", "1970-01-01T00:00:00")).timestamp()
// 3600
* 3600
)
actor = event.udm("actor_user")
account = event.get("recipientAccountId")
rule_id = "AWS.SSM.DistributedCommand"
return f"{rule_id}-{account}-{actor}-{offset}"
def get_cached_instance_ids(key: str) -> set[str]:
"""Get any previously cached parameter names. Included automatic converstion from string in
the case of a unit test mock."""
cached_ids = get_string_set(key, force_ttl_check=True)
if isinstance(cached_ids, str):
# This is a unit test
cached_ids = set(json.loads(cached_ids))
return cached_ids
Rule specification
AnalysisType: rule
Filename: aws_ssm_distributed_command.py
RuleID: "AWS.SSM.DistributedCommand"
DisplayName: AWS SSM Distributed Command
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
Reports:
MITRE ATT&CK:
- TA0002:T1203 # Execution: Exploitation for Client Execution
Stratus Red Team:
- aws.execution.ssm-send-command
Description: >
Detect an attacker utilizing AWS Systems Manager (SSM) to execute commands through SendCommand on multiple EC2 instances.
Reference: >
https://stratus-red-team.cloud/attack-techniques/AWS/aws.execution.ssm-send-command/
Runbook: >
Detetmine who issued the command, the command content and arguments, and which EC2 instances were affected. Determine the risk of an attacker creating a persistent point of access within one of the instances. Review behaviour logs for the EC2 instances (and their associated IAM roles).
SummaryAttributes:
- p_any_aws_account_ids
- p_any_aws_arns
- p_any_aws_instance_ids
- p_any_ip_addresses
- p_any_usernames
Tags:
- AWS CloudTrail
- AWS SSM
- AWS EC2
- 'Execution: Exploitation for Client Execution'
Status: Experimental
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisSendCommand
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"SendCommand" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
Detetmine who issued the command, the command content and arguments, and which EC2 instances were affected. Determine the risk of an attacker creating a persistent point of access within one of the instances. Review behaviour logs for the EC2 instances (and their associated IAM roles).
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "ceaea85a-6db9-4595-9842-d904fce2f047",
"eventName": "SendCommand",
"eventSource": "ssm.amazonaws.com",
"eventTime": "2025-02-19 16:32:39.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_event_time": "2025-02-19 16:32:39.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2025-02-19 16:35:54.509896629",
"readOnly": false,
"recipientAccountId": "111122223333",
"requestID": "c41499e3-c04c-48e7-9da9-ef63d0868553",
"requestParameters": {
"documentName": "AWS-RunShellScript",
"instanceIds": [
"i-006e4c07b5fba8ad2",
"i-01bf673280e708b0d",
"i-020001cca1a2f2628"
],
"interactive": false,
"parameters": "HIDDEN_DUE_TO_SECURITY_REASONS"
},
"responseElements": {
"command": {
"alarmConfiguration": {
"alarms": [],
"ignorePollAlarmFailure": false
},
"clientName": "",
"clientSourceId": "",
"cloudWatchOutputConfig": {
"cloudWatchLogGroupName": "",
"cloudWatchOutputEnabled": false
},
"commandId": "f49a1fe5-d12b-4ac0-98bc-0e4bd83d70c0",
"comment": "",
"completedCount": 0,
"deliveryTimedOutCount": 0,
"documentName": "AWS-RunShellScript",
"documentVersion": "$DEFAULT",
"errorCount": 0,
"expiresAfter": "Feb 19, 2025, 6:32:39 PM",
"hasCancelCommandSignature": false,
"hasSendCommandSignature": false,
"instanceIds": [
"i-006e4c07b5fba8ad2",
"i-01bf673280e708b0d",
"i-020001cca1a2f2628"
],
"interactive": false,
"maxConcurrency": "50",
"maxErrors": "0",
"notificationConfig": {
"notificationArn": "",
"notificationEvents": [],
"notificationType": ""
},
"outputS3BucketName": "",
"outputS3KeyPrefix": "",
"outputS3Region": "us-west-2",
"parameters": "HIDDEN_DUE_TO_SECURITY_REASONS",
"requestedDateTime": "Feb 19, 2025, 4:32:39 PM",
"serviceRole": "",
"status": "Pending",
"statusDetails": "Pending",
"targetCount": 3,
"targets": [],
"timeoutSeconds": 3600,
"triggeredAlarms": []
}
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"clientProvidedHostHeader": "ssm.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.2"
},
"userAgent": "sample-user-agent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY_ID",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/bobson.dugnutt",
"principalId": "SAMPLE_PRINCIPAL_ID:bobson.dugnutt",
"sessionContext": {
"attributes": {
"creationDate": "2025-02-19T16:29:24Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS SSM Multiple Sessions
#Detect when an actor launches multiple distinct SSM sessions within a single hour period.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Execution |
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context, get_actor_user
from panther_core import PantherEvent
def rule(_) -> bool:
return True
def title(event: PantherEvent) -> str:
actor = get_actor_user(event)
aws_account = event.get("recipientAccountId")
return f"Multiple SSM Sessions Started by {actor} in {aws_account}"
def alert_context(event: PantherEvent) -> dict:
return aws_rule_context(event)
Rule specification
AnalysisType: scheduled_rule
Filename: aws_ssm_multiple_sessions.py
RuleID: "AWS.SSM.MultipleSessions"
DisplayName: "AWS SSM Multiple Sessions"
Enabled: true
ScheduledQueries:
- AWS SSM Multiple Sessions
Severity: Info
Reports:
MITRE ATT&CK:
- TA0002:T1203 # Execution: Exploitation for Client Execution
Description: >
Detect when an actor launches multiple distinct SSM sessions within a single hour period.
Reference: >
https://stratus-red-team.cloud/attack-techniques/AWS/aws.execution.ssm-start-session/
Runbook:
Identify the instances for which sessions were started. Monitor instance and session activity. If possible, reach out to the user to determine the reason for multiple sessions.
SummaryAttributes:
- requestParameters.target
Tags:
- AWS CloudTrail
- AWS SSM
- 'Execution: Exploitation for Client Execution'
- Beta
Stages and Predicates
Rule logic
This rule alerts on rows returned by its scheduled query AWS SSM Multiple Sessions; its Python module (Detection logic above) shapes the alert rather than filtering.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Identify the instances for which sessions were started. Monitor instance and session activity. If possible, reach out to the user to determine the reason for multiple sessions.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "c61c9a5d-4d9c-45ee-8c46-9845ea001e97",
"eventName": "StartSession",
"eventSource": "ssm.amazonaws.com",
"eventTime": "2025-02-19 20:13:49",
"eventType": "AwsApiCall",
"eventVersion": "1.10",
"managementEvent": true,
"p_event_time": "2025-02-19 20:13:49",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2025-02-19 20:15:54.4",
"readOnly": false,
"recipientAccountId": "111122223333",
"requestID": "8e6cef5f-2610-43fb-898b-48acd3aa6240",
"requestParameters": {
"target": "i-047fce8bf4806e5ee"
},
"responseElements": {
"sessionId": "bobson.dugnutt-4njnyxxl8yn676nsla8j6l4bra",
"tokenValue": "Value hidden due to security reasons."
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"clientProvidedHostHeader": "ssm.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.2"
},
"userAgent": "sample-user-agent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY_ID",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/bobson.dugnutt",
"principalId": "SAMPLE_PRINCIPAL_ID:bobson.dugnutt",
"sessionContext": {
"attributes": {
"creationDate": "2025-02-19T16:29:24Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS SSM Multiple Sessions
#Returns StartSession events by users who triggered more than 2 StartSession events over the past hour.
Rules detecting the same action
These rules filter on the same operation.
Rule specification
AnalysisType: scheduled_query
QueryName: "AWS SSM Multiple Sessions"
Enabled: false
Description: >
Returns StartSession events by users who triggered more than 2 StartSession events over the past hour.
Tags:
- AWS CloudTrail
- AWS SSM
- AWS EC2
SnowflakeQuery: |-
select * from panther_logs.public.aws_cloudtrail
where eventName = 'StartSession'
and p_occurs_since(1h, , p_parse_time)
qualify count(distinct requestParameters:target) over (partition by userIdentity:arn) > 2
DatabricksQuery: |-
SELECT * FROM (
SELECT
*,
count(distinct requestParameters:target) over (partition by userIdentity:arn) AS session_count
FROM panther_logs.aws_cloudtrail
WHERE eventName = 'StartSession'
AND p_occurs_since(1h, , p_parse_time)
) WHERE session_count > 2
Schedule:
RateMinutes: 60
TimeoutMinutes: 2
Stages and Predicates
Stage 1: source
Stage 2: filter
eventNameisStartSession
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"StartSession" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
* |
AWS SSO Access Token Retrieved by Unauthenticated IP
#When using AWS in an enterprise environment, best practices dictate to use a single sign-on service for identity and access management. AWS SSO is a popular solution, integrating with third-party providers such as Okta and allowing to centrally manage roles and permissions in multiple AWS accounts. In this post, we demonstrate that AWS SSO is vulnerable by design to device code authentication phishing – just like any identity provider implementing OpenID Connect device code authentication. This technique was first demonstrated by Dr. Nestori Syynimaa for Azure AD. The feature provides a powerful phishing vector for attackers, rendering ineffective controls such as MFA (including Yubikeys) or IP allow-listing at the IdP level.
Rule specification
AnalysisType: correlation_rule
RuleID: "AWS.SSO.Access.Token.Retrieved.by.Unauthenticated.IP.Group"
DisplayName: "AWS SSO Access Token Retrieved by Unauthenticated IP"
Enabled: false
Severity: Medium
Description: |-
When using AWS in an enterprise environment, best practices dictate to use a single sign-on service for identity and access management. AWS SSO is a popular solution, integrating with third-party providers such as Okta and allowing to centrally manage roles and permissions in multiple AWS accounts.
In this post, we demonstrate that AWS SSO is vulnerable by design to device code authentication phishing – just like any identity provider implementing OpenID Connect device code authentication. This technique was first demonstrated by Dr. Nestori Syynimaa for Azure AD. The feature provides a powerful phishing vector for attackers, rendering ineffective controls such as MFA (including Yubikeys) or IP allow-listing at the IdP level.
Reference: https://blog.christophetd.fr/phishing-for-aws-credentials-via-aws-sso-device-code-authentication/
Detection:
- Group:
- ID: Absent CLI Prompt
RuleID: Sign-in.with.AWS.CLI.prompt
Absence: true
- ID: SSO Access Token Retrieved
RuleID: Retrieve.SSO.access.token
MatchCriteria:
field_name:
- GroupID: Absent CLI Prompt
Match: sourceIPAddress
- GroupID: SSO Access Token Retrieved
Match: sourceIPAddress
Schedule:
RateMinutes: 1440
TimeoutMinutes: 5
LookbackWindowMinutes: 1800
Stages and Predicates
Fires when the steps below all occur within 30h, correlated by sourceIPAddress. Each step needs one match unless a higher minimum is shown.
Stage 1: step Absent CLI Prompt (negated)
References detection SIGNAL - Sign-in with AWS CLI prompt.
Stage 2: step SSO Access Token Retrieved
References detection SIGNAL - Retrieve SSO access token.
AWS STS GetCallerIdentity via TruffleHog
#Detects AWS STS GetCallerIdentity calls made by TruffleHog, a credential scanning tool. Threat actors use TruffleHog to validate whether leaked or stolen AWS access keys are still active. A GetCallerIdentity call with a TruffleHog user agent indicates that credentials from this account have been discovered externally and are being tested for validity.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return (
event.get("eventSource") == "sts.amazonaws.com"
and event.get("eventName") == "GetCallerIdentity"
and "trufflehog" in event.get("userAgent", "").lower()
)
def title(event):
arn = event.deep_get("userIdentity", "arn", default="<unknown>")
ip_addr = event.get("sourceIPAddress", "<unknown>")
return f"TruffleHog credential validation detected from [{ip_addr}] as [{arn}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_sts_getcalleridentity_trufflehog.py
RuleID: "AWS.STS.GetCallerIdentity.TruffleHog"
DisplayName: "AWS STS GetCallerIdentity via TruffleHog"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- AWS STS
- Discovery:Account Discovery
Reports:
MITRE ATT&CK:
- TA0007:T1087.004
Severity: Medium
Description: >
Detects AWS STS GetCallerIdentity calls made by TruffleHog, a credential scanning
tool. Threat actors use TruffleHog to validate whether leaked or stolen AWS access
keys are still active. A GetCallerIdentity call with a TruffleHog user agent
indicates that credentials from this account have been discovered externally and
are being tested for validity.
Runbook: |
1. Query CloudTrail for all API calls using the same userIdentity:accessKeyId in the 24 hours before and after this alert to assess if the credential has been used for unauthorized actions
2. Check if sourceIPAddress appears in threat intelligence feeds or is associated with known scanning infrastructure
3. Find all other alerts associated with this userIdentity:arn in the past 7 days to determine the scope of potential credential compromise
Reference: https://github.com/trufflesecurity/trufflehog
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceissts.amazonaws.comeventNameisGetCallerIdentityuserAgentcontainstrufflehog
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"GetCallerIdentity" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"sts.amazonaws.com" |
userAgent | contains |
| field:"userAgent" kind:contains value:"trufflehog" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Response runbook
1. Query CloudTrail for all API calls using the same userIdentity:accessKeyId in the 24 hours before and after this alert to assess if the credential has been used for unauthorized actions
2. Check if sourceIPAddress appears in threat intelligence feeds or is associated with known scanning infrastructure
3. Find all other alerts associated with this userIdentity:arn in the past 7 days to determine the scope of potential credential compromise
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventName": "GetCallerIdentity",
"eventSource": "sts.amazonaws.com",
"eventTime": "2024-01-15T10:30:00Z",
"eventType": "AwsApiCall",
"recipientAccountId": "123456789012",
"sourceIPAddress": "198.51.100.42",
"userAgent": "TruffleHog",
"userIdentity": {
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/compromised-user",
"type": "IAMUser",
"userName": "compromised-user"
}
}
AWS STS GetSessionToken by IAM User
#Detects an IAM user calling STS GetSessionToken to obtain temporary credentials. Attackers who have compromised long-term IAM credentials may use GetSessionToken to generate short-lived session tokens for lateral movement or to bypass IP-based policies that apply only to long-term credentials.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation | |
| Lateral Movement |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
if not aws_cloudtrail_success(event):
return False
return (
event.get("eventSource") == "sts.amazonaws.com"
and event.get("eventName") == "GetSessionToken"
and event.deep_get("userIdentity", "type") == "IAMUser"
)
def title(event):
user = event.deep_get("userIdentity", "userName", default="<unknown>")
account = event.get("recipientAccountId", "<unknown>")
return f"IAM user [{user}] called GetSessionToken in account [{account}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_sts_getsessiontoken_misuse.py
RuleID: "AWS.STS.GetSessionToken.Misuse"
DisplayName: "AWS STS GetSessionToken by IAM User"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- AWS STS
- Lateral Movement:Use Alternate Authentication Material
- Privilege Escalation:Abuse Elevation Control Mechanism
Reports:
MITRE ATT&CK:
- TA0008:T1550.001
- TA0004:T1548
Status: Experimental
Severity: Low
Description: >
Detects an IAM user calling STS GetSessionToken to obtain temporary credentials.
Attackers who have compromised long-term IAM credentials may use GetSessionToken
to generate short-lived session tokens for lateral movement or to bypass IP-based
policies that apply only to long-term credentials.
Runbook: |
1. Query CloudTrail for all API calls by userIdentity:arn in the 6 hours before and after this alert to establish what the session token was used for
2. Check if sourceIPAddress is associated with known corporate network ranges, VPN endpoints, or cloud provider IP ranges
3. Find other alerts for this userIdentity:userName in the past 30 days to determine if GetSessionToken usage is part of normal workflow
Reference: https://docs.aws.amazon.com/STS/latest/APIReference/API_GetSessionToken.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceissts.amazonaws.comeventNameisGetSessionTokenuserIdentity.typeisIAMUser
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"GetSessionToken" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"sts.amazonaws.com" |
userIdentity.type | eq |
| field:"aws::userIdentity.type" kind:eq value:"IAMUser" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
userName | userIdentity.userName |
Response runbook
1. Query CloudTrail for all API calls by userIdentity:arn in the 6 hours before and after this alert to establish what the session token was used for
2. Check if sourceIPAddress is associated with known corporate network ranges, VPN endpoints, or cloud provider IP ranges
3. Find other alerts for this userIdentity:userName in the past 30 days to determine if GetSessionToken usage is part of normal workflow
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "abc-123",
"eventName": "GetSessionToken",
"eventSource": "sts.amazonaws.com",
"eventTime": "2024-01-15T10:30:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"recipientAccountId": "123456789012",
"requestID": "req-123",
"sourceIPAddress": "203.0.113.50",
"userAgent": "aws-cli/2.15.0",
"userIdentity": {
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/example-user",
"principalId": "AIDACKCEVSQ6C2EXAMPLE",
"type": "IAMUser",
"userName": "example-user"
}
}
AWS Trusted IPSet Modified
#Detects creation and updates of the list of trusted IPs used by GuardDuty and WAF. Potentially to disable security alerts against malicious IPs.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS GuardDuty Detection Suppression (Elastic)
- AWS GuardDuty Important Change (Sigma)
Detection logic
from panther_aws_helpers import aws_rule_context
IPSET_ACTIONS = ["CreateIPSet", "UpdateIPSet"]
def rule(event):
if (
event.get("eventSource", "") == "guardduty.amazonaws.com"
or event.get("eventSource", "") == "wafv2.amazonaws.com"
):
if event.get("eventName", "") in IPSET_ACTIONS:
return True
return False
def title(event):
return "IPSet was modified in " f"[{event.get('recipientAccountId','')}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: "Detects creation and updates of the list of trusted IPs used by GuardDuty and WAF. Potentially to disable security alerts against malicious IPs."
DisplayName: "AWS Trusted IPSet Modified"
Enabled: true
Filename: aws_ipset_modified.py
Reports:
MITRE ATT&CK:
- TA0005:T1562
Reference: https://docs.aws.amazon.com/managedservices/latest/ctref/management-monitoring-guardduty-ip-set-update-review-required.html
Severity: High
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.IPSet.Modified"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
any of:
eventSourceisguardduty.amazonaws.comeventSourceiswafv2.amazonaws.com
eventNameis one ofCreateIPSet,UpdateIPSet
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsregion": "us-east-1",
"eventid": "abc-123",
"eventname": "CreateIPSet",
"eventsource": "guardduty.amazonaws.com",
"eventtime": "2022-07-17 04:50:23",
"eventtype": "AwsApiCall",
"eventversion": "1.08",
"p_any_aws_instance_ids": [
"testinstanceid"
],
"p_event_time": "2022-07-17 04:50:23",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2022-07-17 04:55:11.788",
"recipientAccountId": "123456789012"
}
AWS Unsuccessful MFA attempt
#Monitor application logs for suspicious events including repeated MFA failures that may indicate user's primary credentials have been compromised.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
if (
event.get("eventSource") != "signin.amazonaws.com"
and event.get("eventName") != "ConsoleLogin"
):
return False
mfa_used = event.deep_get("additionalEventData", "MFAUsed", default="")
console_login = event.deep_get("responseElements", "ConsoleLogin", default="")
if mfa_used == "Yes" and console_login == "Failure":
return True
return False
def title(event):
arn = event.deep_get("userIdenity", "arn", default="No ARN")
username = event.deep_get("userIdentity", "userName", default="No Username")
return f"Failed MFA login from [{arn}] [{username}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: Monitor application logs for suspicious events including repeated MFA failures that may indicate user's primary credentials have been compromised.
DisplayName: "AWS Unsuccessful MFA attempt"
Enabled: false
Filename: aws_cloudtrail_unsuccessful_mfa_attempt.py
Reference: https://attack.mitre.org/techniques/T1621/
Tags:
- Configuration Required # configure threshold for multiple MFA failures
Reports:
MITRE ATT&CK:
- TA0006:T1621
Severity: High
DedupPeriodMinutes: 15
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.Unsuccessful.MFA.attempt"
Threshold: 2
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
any of:
eventSourceissignin.amazonaws.comeventNameisConsoleLogin
additionalEventData.MFAUsedisYesresponseElements.ConsoleLoginisFailure
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
eventName | ne | ConsoleLogin | excludes:eventName field:"eventName" value:"ConsoleLogin" |
eventSource | ne | signin.amazonaws.com | excludes:eventSource field:"eventSource" value:"signin.amazonaws.com" |
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdenity.arn |
userName | userIdentity.userName |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"LoginTo": "https://console.aws.amazon.com/console/home?state=hashArgs%23&isauthcode=true",
"MFAUsed": "Yes",
"MobileVersion": "No"
},
"awsRegion": "us-east-1",
"errorMessage": "Failed authentication",
"eventID": "d38ce1b3-4575-4cb8-a632-611b8243bfc3",
"eventName": "ConsoleLogin",
"eventSource": "signin.amazonaws.com",
"eventTime": "2022-11-10T16:24:34Z",
"eventType": "AwsConsoleSignIn",
"eventVersion": "1.05",
"recipientAccountId": "111122223333",
"requestParameters": null,
"responseElements": {
"ConsoleLogin": "Failure"
},
"sourceIPAddress": "192.0.2.0",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36",
"userIdentity": {
"accessKeyId": "",
"accountId": "111122223333",
"principalId": "AIDACKCEVSQ6C2EXAMPLE",
"type": "IAMUser",
"userName": "anaya"
}
}
AWS Unused Access Key
#This policy validates that IAM user access keys are used at least once every 90 days.
Detection logic
import datetime
from panther_base_helpers import resolve_timestamp_string
TIMEOUT_DAYS = datetime.timedelta(days=90)
DEFAULT_TIME = "0001-01-01T00:00:00Z"
def aged_out(timestamp):
if not timestamp:
return False
datetime_ts = resolve_timestamp_string(timestamp)
if not datetime_ts:
return True
return (datetime.datetime.now() - datetime_ts) > TIMEOUT_DAYS
def policy(resource):
# If a user is less than 4 hours old, it may not have a credential report generated yet.
# It will be re-scanned periodically until a credential report is found, at which point this
# policy will be properly evaluated.
report = resource.get("CredentialReport")
if not report:
return True
if report.get("AccessKey1Active"):
if report.get("AccessKey1LastUsedDate") != DEFAULT_TIME and aged_out(
report.get("AccessKey1LastUsedDate")
):
return False
if report.get("AccessKey1LastUsedDate") == DEFAULT_TIME and aged_out(
report.get("AccessKey1LastRotated")
):
return False
if report.get("AccessKey2Active"):
if report.get("AccessKey2LastUsedDate") != DEFAULT_TIME and aged_out(
report.get("AccessKey2LastUsedDate")
):
return False
if report.get("AccessKey2LastUsedDate") == DEFAULT_TIME and aged_out(
report.get("AccessKey2LastRotated")
):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_access_key_unused.py
PolicyID: "AWS.AccessKey.Unused"
DisplayName: "AWS Unused Access Key"
Enabled: true
ResourceTypes:
- AWS.IAM.User
Tags:
- AWS
- Identity & Access Management
Reports:
CIS:
- 1.3
PCI:
- 8.1.4
Severity: Low
Description: >
This policy validates that IAM user access keys are used at least once every 90 days.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-access-keys-used-every-90-days
Reference: >
https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
Stages and Predicates
Flags AWS.IAM.User resources when the condition below holds.
Condition
CredentialReportis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
CredentialReport | is_null | excludes:CredentialReport |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
CredentialReport | is_not_null | field:"CredentialReport" kind:is_not_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-access-keys-used-every-90-days
AWS User API Key Created
#Detects AWS API key creation for a user by another user. Backdoored users can be used to obtain persistence in the AWS environment.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence | |
| Stealth | |
| Lateral Movement |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS IAM Backdoor Users Keys (Sigma)
- AWS IAM Credentials Added to a Bedrock API Key Phantom User (Elastic)
- AWS IAM S3Browser User or AccessKey Creation (Sigma)
- AWS IAM Sensitive Operations via Lambda Execution Role (Elastic)
- AWS IAM User Created Access Keys For Another User (Elastic)
- AWS IAM User Self-Created Access Key Subsequently Used (Elastic)
- AWS Sensitive IAM Operations Performed via CloudShell (Elastic)
- High-Risk Cross-Cloud User Impersonation (Kusto)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "iam.amazonaws.com"
and event.get("eventName") == "CreateAccessKey"
and (
not event.deep_get("userIdentity", "arn", default="").endswith(
f"user/{event.deep_get('responseElements', 'accessKey', 'userName', default='')}"
)
)
)
def title(event):
return (
f"[{event.deep_get('userIdentity','arn')}]"
" created API keys for "
f"[{event.deep_get('responseElements','accessKey','userName', default = '')}]"
)
def dedup(event):
return f"{event.deep_get('userIdentity','arn')}"
def alert_context(event):
base = aws_rule_context(event)
base["ip_accessKeyId"] = (
event.get("sourceIpAddress", "<NO_IP_ADDRESS>")
+ ":"
+ event.deep_get(
"responseElements", "accessKey", "accessKeyId", default="<NO_ACCESS_KEY_ID>"
)
)
base["request_username"] = event.deep_get(
"requestParameters", "userName", default="USERNAME_NOT_FOUND"
)
return base
Rule specification
AnalysisType: rule
Description: Detects AWS API key creation for a user by another user. Backdoored users can be used to obtain persistence in the AWS environment.
DisplayName: "AWS User API Key Created"
Enabled: true
Filename: aws_iam_user_key_created.py
Reports:
MITRE ATT&CK:
- TA0003:T1098
- TA0005:T1108
- TA0005:T1550
- TA0008:T1550
Stratus Red Team:
- aws.persistence.iam-backdoor-user
- aws.persistence.iam-create-admin-user
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
Severity: Medium
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.IAM.Backdoor.User.Keys"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceisiam.amazonaws.comeventNameisCreateAccessKey
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"CreateAccessKey" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"iam.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
userName | responseElements.accessKey.userName |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "12345",
"eventName": "CreateAccessKey",
"eventSource": "iam.amazonaws.com",
"eventTime": "2022-09-27 17:09:18",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789",
"requestParameters": {
"userName": "user2"
},
"responseElements": {
"accessKey": {
"accessKeyId": "ABCDEFG",
"createDate": "Sep 27, 2022 5:09:18 PM",
"status": "Active",
"userName": "user2"
}
},
"sourceIPAddress": "cloudformation.amazonaws.com",
"userAgent": "cloudformation.amazonaws.com",
"userIdentity": {
"accessKeyId": "ABCDEFGH",
"accountId": "123456789",
"arn": "arn:aws:iam::123456789:user/user1",
"invokedBy": "cloudformation.amazonaws.com",
"principalId": "ABCDEFGH",
"sessionContext": {
"attributes": {
"creationDate": "2022-09-27T17:08:35Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {},
"webIdFederationData": {}
},
"type": "IAMUser",
"userName": "user1"
}
}
AWS User Login Profile Created or Modified
#An attacker with iam:UpdateLoginProfile permission on other users can change the password used to login to the AWS console. May be legitimate account administration.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence | |
| Stealth | |
| Lateral Movement |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS IAM Credentials Added to a Bedrock API Key Phantom User (Elastic)
- AWS IAM Login Profile Added for Root (Elastic)
- AWS IAM Login Profile Added to User (Elastic)
- AWS IAM Login Profile Created or Modified for an IAM User (Elastic)
- AWS IAM S3Browser LoginProfile Creation (Sigma)
- AWS User Login Profile Was Modified (Sigma)
- DEPRECATED - AWS User Login Profile Modified (Panther)
- High-Risk Cross-Cloud User Impersonation (Kusto)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
PROFILE_EVENTS = {
"UpdateLoginProfile",
"CreateLoginProfile",
"DeleteLoginProfile",
}
def rule(event):
# Only look for successes
if not aws_cloudtrail_success(event):
return False
# Check when someone other than the user themselves creates or modifies a login profile
# with no password reset needed
return (
event.get("eventSource", "") == "iam.amazonaws.com"
and event.get("eventName", "") in PROFILE_EVENTS
and not event.deep_get("requestParameters", "passwordResetRequired", default=False)
and not event.deep_get("userIdentity", "arn", default="").endswith(
f"/{event.deep_get('requestParameters', 'userName', default='')}"
)
)
def title(event):
return (
f"[{event.deep_get('userIdentity', 'arn')}] "
f"changed the password for "
f"[{event.deep_get('requestParameters','userName')}]"
)
def alert_context(event):
context = aws_rule_context(event)
context["ip_and_username"] = event.get(
"sourceIPAddress", "<MISSING_SOURCE_IP>"
) + event.deep_get("requestParameters", "userName", default="<MISSING_USER_NAME>")
return context
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_loginprofilecreatedormodified.py
RuleID: "AWS.CloudTrail.LoginProfileCreatedOrModified"
DisplayName: "AWS User Login Profile Created or Modified"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Low
Reports:
MITRE ATT&CK:
- TA0003:T1098
- TA0005:T1108
- TA0005:T1550
- TA0008:T1550
Stratus Red Team:
- aws.persistence.iam-create-user-login-profile
- aws.privilege-escalation.iam-update-user-login-profile
Description: An attacker with iam:UpdateLoginProfile permission on other users can change the password used to login to the AWS console. May be legitimate account administration.
DedupPeriodMinutes: 60
Threshold: 1
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_my-sec-creds-self-manage-pass-accesskeys-ssh.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceisiam.amazonaws.comeventNameis one ofUpdateLoginProfile,CreateLoginProfile,DeleteLoginProfilerequestParameters.passwordResetRequiredis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"iam.amazonaws.com" |
requestParameters.passwordResetRequired | is_null | field:"requestParameters.passwordResetRequired" kind:is_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
userName | requestParameters.userName |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "1234",
"eventName": "UpdateLoginProfile",
"eventSource": "iam.amazonaws.com",
"eventTime": "2022-09-15 13:45:24",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "987654321",
"requestParameters": {
"passwordResetRequired": false,
"userName": "bob"
},
"sessionCredentialFromConsole": true,
"sourceIPAddress": "AWS Internal",
"userAgent": "AWS Internal",
"userIdentity": {
"accessKeyId": "ABC1234",
"accountId": "987654321",
"arn": "arn:aws:sts::98765432:assumed-role/IAM/alice",
"principalId": "ABCDE:alice",
"sessionContext": {
"attributes": {
"creationDate": "2022-09-15T13:36:47Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "987654321",
"arn": "arn:aws:iam::9876432:role/IAM",
"principalId": "1234ABC",
"type": "Role",
"userName": "IAM"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS User Takeover Via Password Reset
#MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Rule specification
AnalysisType: correlation_rule
RuleID: "AWS.User.Takeover.Via.Password.Reset.Group"
DisplayName: "AWS User Takeover Via Password Reset"
Enabled: false
Severity: High
Reports:
MITRE ATT&CK:
- TA0004:T1098.001 # Additional Cloud Credentials
Detection:
- Group:
- ID: Password Reset
RuleID: AWS.CloudTrail.LoginProfileCreatedOrModified
- ID: Login
RuleID: AWS.Console.Login
MatchCriteria:
field_name:
- GroupID: Password Reset
Match: p_alert_context.ip_and_username
- GroupID: Login
Match: p_alert_context.ip_and_username
Schedule:
RateMinutes: 1440
TimeoutMinutes: 10
LookbackWindowMinutes: 1800
Stages and Predicates
Fires when the steps below all occur within 30h, correlated by p_alert_context.ip_and_username. Each step needs one match unless a higher minimum is shown.
Stage 1: step Password Reset
References detection AWS User Login Profile Created or Modified.
Stage 2: step Login
References detection AWS Console Login.
AWS VPC Default Network ACL Restricts All Traffic
#This policy validates that the default Network ACL for a given AWS VPC is restricting all inbound and outbound traffic.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
from panther_aws_helpers import BadLookup, resource_lookup
def policy(resource):
# pylint: disable=line-too-long
default_id = f"arn:aws:ec2:{resource['Region']}:{resource['AccountId']}:network-acl/{resource['DefaultNetworkAclId']}"
try:
default_acl = resource_lookup(default_id)
except BadLookup:
return True
return not default_acl["Entries"]
Rule specification
AnalysisType: policy
Filename: aws_vpc_default_network_acl_restricts_all_traffic.py
PolicyID: "AWS.VPC.DefaultNetworkACLRestrictsAllTraffic"
DisplayName: "AWS VPC Default Network ACL Restricts All Traffic"
Enabled: false
ResourceTypes:
- AWS.EC2.VPC
Tags:
- AWS
- PCI
- Initial Access:External Remote Services
Reports:
PCI:
- 1.2.1
MITRE ATT&CK:
- TA0001:T1133
Severity: Low
Description: >
This policy validates that the default Network ACL for a given AWS VPC is restricting all inbound and outbound traffic.
Runbook: >
Remove all entries allowing traffic from the default Network ACL in each VPC.
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html
#Tests:
# -
# Name: Default Network ACL Has IP Permissions
# ExpectedResult: false
# Resource:
# {
# "CidrBlock": "172.31.0.0/16",
# "CidrBlockAssociationSet": [
# {
# "AssociationId": "vpc-cidr-assoc-112233",
# "CidrBlock": "172.0.0.0/16",
# "CidrBlockState": {
# "State": "associated",
# "StatusMessage": null
# }
# }
# ],
# "DhcpOptionsId": "dopt-1122e3344",
# "FlowLogs": null,
# "InstanceTenancy": "default",
# "Ipv6CidrBlockAssociationSet": null,
# "IsDefault": true,
# "NetworkAcls": [
# {
# "Associations": [
# {
# "NetworkAclAssociationId": "aclassoc-1122abc444",
# "NetworkAclId": "acl-123abc",
# "SubnetId": "subnet-123abc"
# },
# {
# "NetworkAclAssociationId": "aclassoc-123abc",
# "NetworkAclId": "acl-1122abc",
# "SubnetId": "subnet-112233aabb"
# }
# ],
# "Entries": [
# {
# "CidrBlock": "0.0.0.0/0",
# "Egress": false,
# "IcmpTypeCode": null,
# "Ipv6CidrBlock": null,
# "PortRange": null,
# "Protocol": "-1",
# "RuleAction": "deny",
# "RuleNumber": 12345
# }
# ],
# "IsDefault": true,
# "NetworkAclId": "acl-123aabb",
# "OwnerId": "112233445566",
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-1234321"
# }
# ],
# "OwnerId": "112233445566",
# "RouteTables": [
# {
# "Associations": [
# {
# "Main": true,
# "RouteTableAssociationId": "rtbassoc-1122aa33",
# "RouteTableId": "rtb-1122abc33",
# "SubnetId": null
# }
# ],
# "OwnerId": "112233445566",
# "PropagatingVgws": null,
# "RouteTableId": "rtb-11223344",
# "Routes": [
# {
# "DestinationCidrBlock": "0.0.0.0/0",
# "DestinationIpv6CidrBlock": null,
# "DestinationPrefixListId": null,
# "EgressOnlyInternetGatewayId": null,
# "GatewayId": "igw-abc123",
# "InstanceId": null,
# "InstanceOwnerId": null,
# "NatGatewayId": null,
# "NetworkInterfaceId": null,
# "Origin": "CreateRoute",
# "State": "active",
# "TransitGatewayId": null,
# "VpcPeeringConnectionId": null
# }
# ],
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-11223344"
# }
# ],
# "SecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc123",
# "GroupName": "default",
# "IpPermissions": [
# {
# "FromPort": null,
# "IpProtocol": "-1",
# "IpRanges": null,
# "Ipv6Ranges": null,
# "PrefixListIds": null,
# "ToPort": null,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc123",
# "GroupName": null,
# "PeeringStatus": null,
# "UserId": "1122334455",
# "VpcId": null,
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "IpPermissionsEgress": [
# {
# "FromPort": null,
# "IpProtocol": "-1",
# "IpRanges": [
# {
# "CidrIp": "0.0.0.0/0",
# "Description": null
# }
# ],
# "Ipv6Ranges": null,
# "PrefixListIds": null,
# "ToPort": null,
# "UserIdGroupPairs": null
# }
# ],
# "OwnerId": "112233445566",
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-123454321"
# }
# ],
# "StaleSecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "StaleIpPermissions": null,
# "StaleIpPermissionsEgress": [
# {
# "FromPort": 5555,
# "IpProtocol": "tcp",
# "IpRanges": null,
# "PrefixListIds": null,
# "ToPort": 5555,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "PeeringStatus": "deleted",
# "UserId": "123456789012",
# "VpcId": "vpc-abc111222333444",
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "VpcId": "vpc-abc123"
# }
# ],
# "State": "available",
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-1234321"
# }
# -
# Name: Default Network ACL Has Inbound IP Permissions
# ExpectedResult: false
# Resource:
# {
# "CidrBlock": "172.31.0.0/16",
# "CidrBlockAssociationSet": [
# {
# "AssociationId": "vpc-cidr-assoc-112233",
# "CidrBlock": "172.0.0.0/16",
# "CidrBlockState": {
# "State": "associated",
# "StatusMessage": null
# }
# }
# ],
# "DhcpOptionsId": "dopt-1122e3344",
# "FlowLogs": null,
# "InstanceTenancy": "default",
# "Ipv6CidrBlockAssociationSet": null,
# "IsDefault": true,
# "NetworkAcls": [
# {
# "Associations": [
# {
# "NetworkAclAssociationId": "aclassoc-1122abc444",
# "NetworkAclId": "acl-123abc",
# "SubnetId": "subnet-123abc"
# },
# {
# "NetworkAclAssociationId": "aclassoc-123abc",
# "NetworkAclId": "acl-1122abc",
# "SubnetId": "subnet-112233aabb"
# }
# ],
# "Entries": [
# {
# "CidrBlock": "0.0.0.0/0",
# "Egress": false,
# "IcmpTypeCode": null,
# "Ipv6CidrBlock": null,
# "PortRange": null,
# "Protocol": "-1",
# "RuleAction": "deny",
# "RuleNumber": 12345
# }
# ],
# "IsDefault": true,
# "NetworkAclId": "acl-123aabb",
# "OwnerId": "112233445566",
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-1234321"
# }
# ],
# "OwnerId": "112233445566",
# "RouteTables": [
# {
# "Associations": [
# {
# "Main": true,
# "RouteTableAssociationId": "rtbassoc-1122aa33",
# "RouteTableId": "rtb-1122abc33",
# "SubnetId": null
# }
# ],
# "OwnerId": "112233445566",
# "PropagatingVgws": null,
# "RouteTableId": "rtb-11223344",
# "Routes": [
# {
# "DestinationCidrBlock": "0.0.0.0/0",
# "DestinationIpv6CidrBlock": null,
# "DestinationPrefixListId": null,
# "EgressOnlyInternetGatewayId": null,
# "GatewayId": "igw-abc123",
# "InstanceId": null,
# "InstanceOwnerId": null,
# "NatGatewayId": null,
# "NetworkInterfaceId": null,
# "Origin": "CreateRoute",
# "State": "active",
# "TransitGatewayId": null,
# "VpcPeeringConnectionId": null
# }
# ],
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-11223344"
# }
# ],
# "SecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc123",
# "GroupName": "default",
# "IpPermissions": [
# {
# "FromPort": null,
# "IpProtocol": "-1",
# "IpRanges": null,
# "Ipv6Ranges": null,
# "PrefixListIds": null,
# "ToPort": null,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc123",
# "GroupName": null,
# "PeeringStatus": null,
# "UserId": "1122334455",
# "VpcId": null,
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "IpPermissionsEgress": null,
# "OwnerId": "112233445566",
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-123454321"
# }
# ],
# "StaleSecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "StaleIpPermissions": null,
# "StaleIpPermissionsEgress": [
# {
# "FromPort": 5555,
# "IpProtocol": "tcp",
# "IpRanges": null,
# "PrefixListIds": null,
# "ToPort": 5555,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "PeeringStatus": "deleted",
# "UserId": "123456789012",
# "VpcId": "vpc-abc111222333444",
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "VpcId": "vpc-abc123"
# }
# ],
# "State": "available",
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-1234321"
# }
# -
# Name: Default Network ACL Has No IP Permissions
# ExpectedResult: true
# Resource:
# {
# "CidrBlock": "172.31.0.0/16",
# "CidrBlockAssociationSet": [
# {
# "AssociationId": "vpc-cidr-assoc-112233",
# "CidrBlock": "172.0.0.0/16",
# "CidrBlockState": {
# "State": "associated",
# "StatusMessage": null
# }
# }
# ],
# "DhcpOptionsId": "dopt-1122e3344",
# "FlowLogs": null,
# "InstanceTenancy": "default",
# "Ipv6CidrBlockAssociationSet": null,
# "IsDefault": true,
# "NetworkAcls": [
# {
# "Associations": [
# {
# "NetworkAclAssociationId": "aclassoc-1122abc444",
# "NetworkAclId": "acl-123abc",
# "SubnetId": "subnet-123abc"
# },
# {
# "NetworkAclAssociationId": "aclassoc-123abc",
# "NetworkAclId": "acl-1122abc",
# "SubnetId": "subnet-112233aabb"
# }
# ],
# "Entries": null,
# "IsDefault": true,
# "NetworkAclId": "acl-123aabb",
# "OwnerId": "112233445566",
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-1234321"
# }
# ],
# "OwnerId": "112233445566",
# "RouteTables": [
# {
# "Associations": [
# {
# "Main": true,
# "RouteTableAssociationId": "rtbassoc-1122aa33",
# "RouteTableId": "rtb-1122abc33",
# "SubnetId": null
# }
# ],
# "OwnerId": "112233445566",
# "PropagatingVgws": null,
# "RouteTableId": "rtb-11223344",
# "Routes": [
# {
# "DestinationCidrBlock": "0.0.0.0/0",
# "DestinationIpv6CidrBlock": null,
# "DestinationPrefixListId": null,
# "EgressOnlyInternetGatewayId": null,
# "GatewayId": "igw-abc123",
# "InstanceId": null,
# "InstanceOwnerId": null,
# "NatGatewayId": null,
# "NetworkInterfaceId": null,
# "Origin": "CreateRoute",
# "State": "active",
# "TransitGatewayId": null,
# "VpcPeeringConnectionId": null
# }
# ],
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-11223344"
# }
# ],
# "SecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc123",
# "GroupName": "default",
# "IpPermissions": null,
# "IpPermissionsEgress": null,
# "OwnerId": "112233445566",
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-123454321"
# }
# ],
# "StaleSecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "StaleIpPermissions": null,
# "StaleIpPermissionsEgress": [
# {
# "FromPort": 5555,
# "IpProtocol": "tcp",
# "IpRanges": null,
# "PrefixListIds": null,
# "ToPort": 5555,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "PeeringStatus": "deleted",
# "UserId": "123456789012",
# "VpcId": "vpc-abc111222333444",
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "VpcId": "vpc-abc123"
# }
# ],
# "State": "available",
# "Tags": {
# "environment": "pci",
# },
# "VpcId": "vpc-1234321"
# }
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
Remove all entries allowing traffic from the default Network ACL in each VPC.
AWS VPC Default Security Group Restrictions
#This policy validates that the default Security Group for a given AWS VPC is restricting all inbound and outbound traffic.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
from panther_aws_helpers import BadLookup, resource_lookup
def policy(resource):
# pylint: disable=line-too-long
default_id = f"arn:aws:ec2:{resource['Region']}:{resource['AccountId']}:security-group/{resource['DefaultSecurityGroupId']}"
try:
default_sg = resource_lookup(default_id)
except BadLookup:
return True
return default_sg["IpPermissions"] is None and default_sg["IpPermissionsEgress"] is None
Rule specification
AnalysisType: policy
Filename: aws_vpc_default_security_restrictions.py
PolicyID: "AWS.VPC.DefaultSecurityGroup.Restrictions"
DisplayName: "AWS VPC Default Security Group Restrictions "
Enabled: true
ResourceTypes:
- AWS.EC2.VPC
Tags:
- AWS
- Security Control
- Initial Access:External Remote Services
Reports:
CIS:
- 4.3
PCI:
- 1.2.1
MITRE ATT&CK:
- TA0001:T1133
Severity: Low
Description: >
This policy validates that the default Security Group for a given AWS VPC is restricting all inbound and outbound traffic.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-vpc-default-security-group-restricts-all-traffic
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html
#Tests:
# -
# Name: Default Security Group Has IP Permissions
# ExpectedResult: false
# Resource:
# {
# "CidrBlock": "172.31.0.0/16",
# "CidrBlockAssociationSet": [
# {
# "AssociationId": "vpc-cidr-assoc-112233",
# "CidrBlock": "172.0.0.0/16",
# "CidrBlockState": {
# "State": "associated",
# "StatusMessage": null
# }
# }
# ],
# "DhcpOptionsId": "dopt-1122e3344",
# "FlowLogs": null,
# "InstanceTenancy": "default",
# "Ipv6CidrBlockAssociationSet": null,
# "IsDefault": true,
# "NetworkAcls": [
# {
# "Associations": [
# {
# "NetworkAclAssociationId": "aclassoc-1122abc444",
# "NetworkAclId": "acl-123abc",
# "SubnetId": "subnet-123abc"
# },
# {
# "NetworkAclAssociationId": "aclassoc-123abc",
# "NetworkAclId": "acl-1122abc",
# "SubnetId": "subnet-112233aabb"
# }
# ],
# "Entries": [
# {
# "CidrBlock": "0.0.0.0/0",
# "Egress": false,
# "IcmpTypeCode": null,
# "Ipv6CidrBlock": null,
# "PortRange": null,
# "Protocol": "-1",
# "RuleAction": "deny",
# "RuleNumber": 12345
# }
# ],
# "IsDefault": true,
# "NetworkAclId": "acl-123aabb",
# "OwnerId": "112233445566",
# "Tags": null,
# "VpcId": "vpc-1234321"
# }
# ],
# "OwnerId": "112233445566",
# "RouteTables": [
# {
# "Associations": [
# {
# "Main": true,
# "RouteTableAssociationId": "rtbassoc-1122aa33",
# "RouteTableId": "rtb-1122abc33",
# "SubnetId": null
# }
# ],
# "OwnerId": "112233445566",
# "PropagatingVgws": null,
# "RouteTableId": "rtb-11223344",
# "Routes": [
# {
# "DestinationCidrBlock": "0.0.0.0/0",
# "DestinationIpv6CidrBlock": null,
# "DestinationPrefixListId": null,
# "EgressOnlyInternetGatewayId": null,
# "GatewayId": "igw-abc123",
# "InstanceId": null,
# "InstanceOwnerId": null,
# "NatGatewayId": null,
# "NetworkInterfaceId": null,
# "Origin": "CreateRoute",
# "State": "active",
# "TransitGatewayId": null,
# "VpcPeeringConnectionId": null
# }
# ],
# "Tags": null,
# "VpcId": "vpc-11223344"
# }
# ],
# "SecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc123",
# "GroupName": "default",
# "IpPermissions": [
# {
# "FromPort": null,
# "IpProtocol": "-1",
# "IpRanges": null,
# "Ipv6Ranges": null,
# "PrefixListIds": null,
# "ToPort": null,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc123",
# "GroupName": null,
# "PeeringStatus": null,
# "UserId": "1122334455",
# "VpcId": null,
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "IpPermissionsEgress": [
# {
# "FromPort": null,
# "IpProtocol": "-1",
# "IpRanges": [
# {
# "CidrIp": "0.0.0.0/0",
# "Description": null
# }
# ],
# "Ipv6Ranges": null,
# "PrefixListIds": null,
# "ToPort": null,
# "UserIdGroupPairs": null
# }
# ],
# "OwnerId": "112233445566",
# "Tags": null,
# "VpcId": "vpc-123454321"
# }
# ],
# "StaleSecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "StaleIpPermissions": null,
# "StaleIpPermissionsEgress": [
# {
# "FromPort": 5555,
# "IpProtocol": "tcp",
# "IpRanges": null,
# "PrefixListIds": null,
# "ToPort": 5555,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "PeeringStatus": "deleted",
# "UserId": "123456789012",
# "VpcId": "vpc-abc111222333444",
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "VpcId": "vpc-abc123"
# }
# ],
# "State": "available",
# "Tags": null,
# "VpcId": "vpc-1234321"
# }
# -
# Name: Default Security Group Has Inbound IP Permissions
# ExpectedResult: false
# Resource:
# {
# "CidrBlock": "172.31.0.0/16",
# "CidrBlockAssociationSet": [
# {
# "AssociationId": "vpc-cidr-assoc-112233",
# "CidrBlock": "172.0.0.0/16",
# "CidrBlockState": {
# "State": "associated",
# "StatusMessage": null
# }
# }
# ],
# "DhcpOptionsId": "dopt-1122e3344",
# "FlowLogs": null,
# "InstanceTenancy": "default",
# "Ipv6CidrBlockAssociationSet": null,
# "IsDefault": true,
# "NetworkAcls": [
# {
# "Associations": [
# {
# "NetworkAclAssociationId": "aclassoc-1122abc444",
# "NetworkAclId": "acl-123abc",
# "SubnetId": "subnet-123abc"
# },
# {
# "NetworkAclAssociationId": "aclassoc-123abc",
# "NetworkAclId": "acl-1122abc",
# "SubnetId": "subnet-112233aabb"
# }
# ],
# "Entries": [
# {
# "CidrBlock": "0.0.0.0/0",
# "Egress": false,
# "IcmpTypeCode": null,
# "Ipv6CidrBlock": null,
# "PortRange": null,
# "Protocol": "-1",
# "RuleAction": "deny",
# "RuleNumber": 12345
# }
# ],
# "IsDefault": true,
# "NetworkAclId": "acl-123aabb",
# "OwnerId": "112233445566",
# "Tags": null,
# "VpcId": "vpc-1234321"
# }
# ],
# "OwnerId": "112233445566",
# "RouteTables": [
# {
# "Associations": [
# {
# "Main": true,
# "RouteTableAssociationId": "rtbassoc-1122aa33",
# "RouteTableId": "rtb-1122abc33",
# "SubnetId": null
# }
# ],
# "OwnerId": "112233445566",
# "PropagatingVgws": null,
# "RouteTableId": "rtb-11223344",
# "Routes": [
# {
# "DestinationCidrBlock": "0.0.0.0/0",
# "DestinationIpv6CidrBlock": null,
# "DestinationPrefixListId": null,
# "EgressOnlyInternetGatewayId": null,
# "GatewayId": "igw-abc123",
# "InstanceId": null,
# "InstanceOwnerId": null,
# "NatGatewayId": null,
# "NetworkInterfaceId": null,
# "Origin": "CreateRoute",
# "State": "active",
# "TransitGatewayId": null,
# "VpcPeeringConnectionId": null
# }
# ],
# "Tags": null,
# "VpcId": "vpc-11223344"
# }
# ],
# "SecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc123",
# "GroupName": "default",
# "IpPermissions": [
# {
# "FromPort": null,
# "IpProtocol": "-1",
# "IpRanges": null,
# "Ipv6Ranges": null,
# "PrefixListIds": null,
# "ToPort": null,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc123",
# "GroupName": null,
# "PeeringStatus": null,
# "UserId": "1122334455",
# "VpcId": null,
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "IpPermissionsEgress": null,
# "OwnerId": "112233445566",
# "Tags": null,
# "VpcId": "vpc-123454321"
# }
# ],
# "StaleSecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "StaleIpPermissions": null,
# "StaleIpPermissionsEgress": [
# {
# "FromPort": 5555,
# "IpProtocol": "tcp",
# "IpRanges": null,
# "PrefixListIds": null,
# "ToPort": 5555,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "PeeringStatus": "deleted",
# "UserId": "123456789012",
# "VpcId": "vpc-abc111222333444",
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "VpcId": "vpc-abc123"
# }
# ],
# "State": "available",
# "Tags": null,
# "VpcId": "vpc-1234321"
# }
# -
# Name: Default Security Group Has No IP Permissions
# ExpectedResult: true
# Resource:
# {
# "CidrBlock": "172.31.0.0/16",
# "CidrBlockAssociationSet": [
# {
# "AssociationId": "vpc-cidr-assoc-112233",
# "CidrBlock": "172.0.0.0/16",
# "CidrBlockState": {
# "State": "associated",
# "StatusMessage": null
# }
# }
# ],
# "DhcpOptionsId": "dopt-1122e3344",
# "FlowLogs": null,
# "InstanceTenancy": "default",
# "Ipv6CidrBlockAssociationSet": null,
# "IsDefault": true,
# "NetworkAcls": [
# {
# "Associations": [
# {
# "NetworkAclAssociationId": "aclassoc-1122abc444",
# "NetworkAclId": "acl-123abc",
# "SubnetId": "subnet-123abc"
# },
# {
# "NetworkAclAssociationId": "aclassoc-123abc",
# "NetworkAclId": "acl-1122abc",
# "SubnetId": "subnet-112233aabb"
# }
# ],
# "Entries": [
# {
# "CidrBlock": "0.0.0.0/0",
# "Egress": false,
# "IcmpTypeCode": null,
# "Ipv6CidrBlock": null,
# "PortRange": null,
# "Protocol": "-1",
# "RuleAction": "deny",
# "RuleNumber": 12345
# }
# ],
# "IsDefault": true,
# "NetworkAclId": "acl-123aabb",
# "OwnerId": "112233445566",
# "Tags": null,
# "VpcId": "vpc-1234321"
# }
# ],
# "OwnerId": "112233445566",
# "RouteTables": [
# {
# "Associations": [
# {
# "Main": true,
# "RouteTableAssociationId": "rtbassoc-1122aa33",
# "RouteTableId": "rtb-1122abc33",
# "SubnetId": null
# }
# ],
# "OwnerId": "112233445566",
# "PropagatingVgws": null,
# "RouteTableId": "rtb-11223344",
# "Routes": [
# {
# "DestinationCidrBlock": "0.0.0.0/0",
# "DestinationIpv6CidrBlock": null,
# "DestinationPrefixListId": null,
# "EgressOnlyInternetGatewayId": null,
# "GatewayId": "igw-abc123",
# "InstanceId": null,
# "InstanceOwnerId": null,
# "NatGatewayId": null,
# "NetworkInterfaceId": null,
# "Origin": "CreateRoute",
# "State": "active",
# "TransitGatewayId": null,
# "VpcPeeringConnectionId": null
# }
# ],
# "Tags": null,
# "VpcId": "vpc-11223344"
# }
# ],
# "SecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc123",
# "GroupName": "default",
# "IpPermissions": null,
# "IpPermissionsEgress": null,
# "OwnerId": "112233445566",
# "Tags": null,
# "VpcId": "vpc-123454321"
# }
# ],
# "StaleSecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "StaleIpPermissions": null,
# "StaleIpPermissionsEgress": [
# {
# "FromPort": 5555,
# "IpProtocol": "tcp",
# "IpRanges": null,
# "PrefixListIds": null,
# "ToPort": 5555,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "PeeringStatus": "deleted",
# "UserId": "123456789012",
# "VpcId": "vpc-abc111222333444",
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "VpcId": "vpc-abc123"
# }
# ],
# "State": "available",
# "Tags": null,
# "VpcId": "vpc-1234321"
# }
# -
# Name: Default Security Group Has Outbound IP Permissions
# ExpectedResult: false
# Resource:
# {
# "CidrBlock": "172.31.0.0/16",
# "CidrBlockAssociationSet": [
# {
# "AssociationId": "vpc-cidr-assoc-112233",
# "CidrBlock": "172.0.0.0/16",
# "CidrBlockState": {
# "State": "associated",
# "StatusMessage": null
# }
# }
# ],
# "DhcpOptionsId": "dopt-1122e3344",
# "FlowLogs": null,
# "InstanceTenancy": "default",
# "Ipv6CidrBlockAssociationSet": null,
# "IsDefault": true,
# "NetworkAcls": [
# {
# "Associations": [
# {
# "NetworkAclAssociationId": "aclassoc-1122abc444",
# "NetworkAclId": "acl-123abc",
# "SubnetId": "subnet-123abc"
# },
# {
# "NetworkAclAssociationId": "aclassoc-123abc",
# "NetworkAclId": "acl-1122abc",
# "SubnetId": "subnet-112233aabb"
# }
# ],
# "Entries": [
# {
# "CidrBlock": "0.0.0.0/0",
# "Egress": false,
# "IcmpTypeCode": null,
# "Ipv6CidrBlock": null,
# "PortRange": null,
# "Protocol": "-1",
# "RuleAction": "deny",
# "RuleNumber": 12345
# }
# ],
# "IsDefault": true,
# "NetworkAclId": "acl-123aabb",
# "OwnerId": "112233445566",
# "Tags": null,
# "VpcId": "vpc-1234321"
# }
# ],
# "OwnerId": "112233445566",
# "RouteTables": [
# {
# "Associations": [
# {
# "Main": true,
# "RouteTableAssociationId": "rtbassoc-1122aa33",
# "RouteTableId": "rtb-1122abc33",
# "SubnetId": null
# }
# ],
# "OwnerId": "112233445566",
# "PropagatingVgws": null,
# "RouteTableId": "rtb-11223344",
# "Routes": [
# {
# "DestinationCidrBlock": "0.0.0.0/0",
# "DestinationIpv6CidrBlock": null,
# "DestinationPrefixListId": null,
# "EgressOnlyInternetGatewayId": null,
# "GatewayId": "igw-abc123",
# "InstanceId": null,
# "InstanceOwnerId": null,
# "NatGatewayId": null,
# "NetworkInterfaceId": null,
# "Origin": "CreateRoute",
# "State": "active",
# "TransitGatewayId": null,
# "VpcPeeringConnectionId": null
# }
# ],
# "Tags": null,
# "VpcId": "vpc-11223344"
# }
# ],
# "SecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc123",
# "GroupName": "default",
# "IpPermissions": null,
# "IpPermissionsEgress": [
# {
# "FromPort": null,
# "IpProtocol": "-1",
# "IpRanges": [
# {
# "CidrIp": "0.0.0.0/0",
# "Description": null
# }
# ],
# "Ipv6Ranges": null,
# "PrefixListIds": null,
# "ToPort": null,
# "UserIdGroupPairs": null
# }
# ],
# "OwnerId": "112233445566",
# "Tags": null,
# "VpcId": "vpc-123454321"
# }
# ],
# "StaleSecurityGroups": [
# {
# "Description": "default VPC security group",
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "StaleIpPermissions": null,
# "StaleIpPermissionsEgress": [
# {
# "FromPort": 5555,
# "IpProtocol": "tcp",
# "IpRanges": null,
# "PrefixListIds": null,
# "ToPort": 5555,
# "UserIdGroupPairs": [
# {
# "Description": null,
# "GroupId": "sg-abc567",
# "GroupName": "default",
# "PeeringStatus": "deleted",
# "UserId": "123456789012",
# "VpcId": "vpc-abc111222333444",
# "VpcPeeringConnectionId": null
# }
# ]
# }
# ],
# "VpcId": "vpc-abc123"
# }
# ],
# "State": "available",
# "Tags": null,
# "VpcId": "vpc-1234321"
# }
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
AWS VPC Flow Logs
#This policy validates that AWS VPCs (Virtual Private Clouds) have network flow logging enabled.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
def policy(resource):
if resource["FlowLogs"] is None:
return False
for flow in resource["FlowLogs"]:
if flow["FlowLogStatus"] == "ACTIVE" and (
flow["TrafficType"] == "REJECT" or flow["TrafficType"] == "ALL"
):
return True
return False
Rule specification
AnalysisType: policy
Filename: aws_vpc_flow_logs.py
PolicyID: "AWS.VPC.FlowLogs"
DisplayName: "AWS VPC Flow Logs"
Enabled: true
ResourceTypes:
- AWS.EC2.VPC
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 2.9
MITRE ATT&CK:
- TA0005:T1562
Severity: Medium
Description: >
This policy validates that AWS VPCs (Virtual Private Clouds) have network flow logging enabled.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-vpc-flow-logging-enabled
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html
Stages and Predicates
Flags AWS.EC2.VPC resources when any of the conditions below holds.
Condition
any of:
FlowLogsis emptyno element of
FlowLogsmatches all of:FlowLogs.FlowLogStatusisACTIVEany of:
FlowLogs.TrafficTypeisREJECTFlowLogs.TrafficTypeisALL
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
FlowLogs | array_any | excludes:FlowLogs | |
FlowLogs | is_not_null | excludes:FlowLogs |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
FlowLogs | is_null | field:"FlowLogs" kind:is_null |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-vpc-flow-logging-enabled
AWS VPC Flow Logs Removed
#Detects when logs for a VPC have been removed.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event DeleteFlowLogs: Deletes one or more VPC flow logs. |
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") == "DeleteFlowLogs"
def title(event):
account = event.deep_get("userIdentity", "accountId", default="<UNKNOWN ACCOUNT>")
region = event.get("awsRegion", "<UNKNOWN REGION>")
return f"VPC Flow logs have been deleted in {account} in {region}"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_vpc_flow_logs_deleted.py
RuleID: "AWS.VPCFlow.LogsDeleted"
DisplayName: "AWS VPC Flow Logs Removed"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
Reports:
MITRE ATT&CK:
- TA0005:T1562.008 # Defense Evasion: Disable or Modify Cloud Logs
Stratus Red Team:
- aws.defense-evasion.vpc-remove-flow-logs
Description: "Detects when logs for a VPC have been removed."
Reference:
https://stratus-red-team.cloud/attack-techniques/AWS/aws.defense-evasion.vpc-remove-flow-logs/
Runbook: Look for an accompanying 'DeleteVpc' event, and confirm that they are related. if there is no matching VPC Deletion event, followup with the log removal to determine if it is legitimate.
Tags:
- AWS
- Cloudtrail
- Defense Evasion
- Impair Defenses
- Disable or Modify Cloud Logs
- Defense Evasion:Impair Defenses
- Security Control
Status: Experimental
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisDeleteFlowLogs
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"DeleteFlowLogs" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
accountId | userIdentity.accountId |
Response runbook
Look for an accompanying 'DeleteVpc' event, and confirm that they are related. if there is no matching VPC Deletion event, followup with the log removal to determine if it is legitimate.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "d5e6d49c-0be9-4c53-ab8a-c7ca86edd130",
"eventName": "DeleteFlowLogs",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2024-11-26 19:29:38.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.10",
"managementEvent": true,
"p_event_time": "2024-11-26 19:29:38.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2024-11-26 19:35:54.358700257",
"readOnly": false,
"recipientAccountId": "111122223333",
"requestID": "1dcd7d36-72be-4aad-9e9d-93b88e0135ea",
"requestParameters": {
"DeleteFlowLogsRequest": {
"FlowLogId": {
"content": "fl-0ef673ef70c4f07cc",
"tag": 1
}
}
},
"responseElements": {
"DeleteFlowLogsResponse": {
"requestId": "1dcd7d36-72be-4aad-9e9d-93b88e0135ea",
"unsuccessful": "",
"xmlns": "http://ec2.amazonaws.com/doc/2016-11-15/"
}
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "ec2.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "sample-user-agent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/leroy.jenkins",
"principalId": "SAMPLE_PRINCIPAL_ID:leroy.jenkins",
"sessionContext": {
"attributes": {
"creationDate": "2024-11-26T17:05:50Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "SAMPLE_PRINCIPAL_ID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
AWS VPC Healthy Log Status
#Checks for the log status SKIPDATA, which indicates that data was lost either to an internal server error or due to capacity constraints.
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return event.udm("log_status") == "SKIPDATA"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_vpc_healthy_log_status.py
RuleID: "AWS.VPC.HealthyLogStatus"
DisplayName: "AWS VPC Healthy Log Status"
Enabled: true
LogTypes:
- AWS.VPCFlow
- OCSF.NetworkActivity
Tags:
- AWS
- DataModel
- Security Control
Severity: Info
CreateAlert: false
DedupPeriodMinutes: 1440
Description: >
Checks for the log status `SKIPDATA`, which indicates that data was lost either to an internal server error or due to capacity constraints.
Reference: https://www.reddit.com/r/aws/comments/zt1xhg/vpc_flow_logs_when_is_logstatus_skipdata_a_concern/?rdt=41505
Runbook: >
Determine if the cause of the issue is capacity constraints, and consider adjusting VPC Flow Log configurations accordingly.
Stages and Predicates
Fires on AWS.VPCFlow, OCSF.NetworkActivity events when the condition below holds.
Condition
log_statusisSKIPDATA
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
log_status | eq |
| field:"log_status" kind:eq value:"SKIPDATA" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Determine if the cause of the issue is capacity constraints, and consider adjusting VPC Flow Log configurations accordingly.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"p_log_type": "AWS.VPCFlow",
"status": "SKIPDATA"
}
AWS WAF Disassociation
#Detects when AWS WAF is disassociated from protected resources such as Application Load Balancers, API Gateway, CloudFront, or AppSync. Removing WAF protection exposes applications to SQL injection, XSS, DDoS attacks, and OWASP Top 10 vulnerabilities. Attackers often disable WAF before launching attacks, or this may indicate misconfiguration or unauthorized changes.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
def rule(event):
return event.get("eventName") == "DisassociateWebACL"
def title(event):
return (
f"AWS Account ID [{event.get('recipientAccountId')}] "
f"disassociated WebACL [{event.deep_get('requestParameters', 'resourceArn')}]"
)
def alert_context(event):
return {
"awsRegion": event.get("awsRegion"),
"eventName": event.get("eventName"),
"recipientAccountId": event.get("recipientAccountId"),
"requestID": event.get("requestID"),
"actor_user": event.udm("actor_user"),
"requestParameters": event.deep_get("requestParameters", "resourceArn"),
"userIdentity": event.deep_get("userIdentity", "principalId"),
}
Rule specification
AnalysisType: rule
Description: >
Detects when AWS WAF is disassociated from protected resources such as Application Load Balancers, API Gateway, CloudFront, or AppSync. Removing WAF protection exposes applications to SQL injection, XSS, DDoS attacks, and OWASP Top 10 vulnerabilities. Attackers often disable WAF before launching attacks, or this may indicate misconfiguration or unauthorized changes.
DisplayName: "AWS WAF Disassociation"
Enabled: true
Filename: aws_waf_disassociation.py
Reference: https://docs.aws.amazon.com/waf/latest/APIReference/API_DisassociateWebACL.html
Runbook: |
1. Query ALB access logs, CloudFront logs, or API Gateway logs for requestParameters.resourceArn in the timeframe between this DisassociateWebACL event and WAF re-association to identify attack traffic that reached the unprotected resource
2. Search for SQL injection patterns, XSS payloads, or unusual request volumes in the application logs that may indicate exploitation while WAF protection was removed
3. Review CloudTrail for other security-related API calls by userIdentity.arn in the 6 hours around this event to identify if other defensive controls were modified or disabled
Severity: Critical
Tags:
- AWS
- Web Application Firewall
- Defense Evasion
- Impair Defenses
- Network Security
Reports:
MITRE ATT&CK:
- TA0005:T1562.004
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.WAF.Disassociation"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when the condition below holds.
Condition
eventNameisDisassociateWebACL
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"DisassociateWebACL" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
awsRegion | |
eventName | |
recipientAccountId | |
requestID | |
actor_user | |
requestParameters | requestParameters.resourceArn |
userIdentity | userIdentity.principalId |
Response runbook
1. Query ALB access logs, CloudFront logs, or API Gateway logs for requestParameters.resourceArn in the timeframe between this DisassociateWebACL event and WAF re-association to identify attack traffic that reached the unprotected resource
2. Search for SQL injection patterns, XSS payloads, or unusual request volumes in the application logs that may indicate exploitation while WAF protection was removed
3. Review CloudTrail for other security-related API calls by userIdentity.arn in the 6 hours around this event to identify if other defensive controls were modified or disabled
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"apiVersion": "2019-04-23",
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "94678efc-2176-462c-b0c9-a612881a39ed",
"eventName": "DisassociateWebACL",
"eventSource": "wafv2.amazonaws.com",
"eventTime": "2022-09-29 23:04:35",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_any_aws_account_ids": [
"012345678910"
],
"p_any_aws_arns": [
"arn:aws:elasticloadbalancing:us-west-2:012345678910:loadbalancer/app/web/84dc5457e450dba5",
"arn:aws:iam::012345678910:role/DevAdministrator",
"arn:aws:sts::012345678910:assumed-role/DevAdministrator/example_user"
],
"p_any_domain_names": [
"AWS Internal"
],
"p_any_trace_ids": [
"ASIARLIVEKVNJNSTUSF6"
],
"p_event_time": "2022-09-29 23:04:35",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2022-09-29 23:08:26.172",
"p_row_id": "5ad3a83ca88f938cbff8fdd913d1ce1d",
"p_source_id": "125a8146-e3ea-454b-aed7-9e08e735b670",
"p_source_label": "Panther Identity Org CloudTrail",
"readOnly": false,
"recipientAccountId": "012345678910",
"requestID": "e4d47992-90f1-47f0-bff7-de18a8277005",
"requestParameters": {
"resourceArn": "arn:aws:elasticloadbalancing:us-west-2:012345678910:loadbalancer/app/web/84dc5457e450dba5"
},
"sessionCredentialFromConsole": true,
"sourceIPAddress": "AWS Internal",
"userAgent": "AWS Internal",
"userIdentity": {
"accessKeyId": "ASIARLIVEKVNJNSTUSF6",
"accountId": "012345678910",
"arn": "arn:aws:sts::012345678910:assumed-role/DevAdministrator/example_user",
"principalId": "AROARLIVEKVNIRVGDLJWJ:example_user",
"sessionContext": {
"attributes": {
"creationDate": "2022-09-29T22:51:13Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "012345678910",
"arn": "arn:aws:iam::012345678910:role/DevAdministrator",
"principalId": "AROARLIVEKVNIRVGDLJWJ",
"type": "Role",
"userName": "DevAdministrator"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
AWS WAF Has XSS Predicate
#This policy validates that all WAF's have at least one rule with a predicate matching on and blocking XSS attacks.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
from panther_base_helpers import deep_get
def policy(resource):
for rule in resource["Rules"] or []:
# Must block the XSS
if deep_get(rule, "Action", "Type") != "BLOCK":
continue
# Only passes if there is an XSS matching predicate
for predicate in rule["Predicates"]:
if predicate["Type"] == "XssMatch":
return True
return False
Rule specification
AnalysisType: policy
Filename: aws_waf_has_xss_predicate.py
PolicyID: "AWS.WAF.HasXSSPredicate"
DisplayName: "AWS WAF Has XSS Predicate"
Enabled: false
ResourceTypes:
- AWS.WAF.Regional.WebACL
- AWS.WAF.WebACL
Tags:
- AWS
- PCI
- Initial Access:Exploit Public-Facing Application
Reports:
PCI:
- 6.5.7
MITRE ATT&CK:
- TA0001:T1190
Severity: Medium
Description: >
This policy validates that all WAF's have at least one rule with a predicate matching on and blocking XSS attacks.
Runbook: >
Configure a web ACL rule with a XSS matching predicate and add it to the WAF.
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/web-acl-xss-conditions.html
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
Configure a web ACL rule with a XSS matching predicate and add it to the WAF.
AWS WAF Logging Configured
#Ensures that AWS WAF logging is enabled and that the logs are being sent to a valid destination (S3, CloudWatch, or Kinesis Firehose). Without logging, visibility into WAF activity is severely limited, increasing the risk of undetected attacks.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Detection logic
def is_valid_arn(arn, service):
if service == "logs":
return arn.startswith("arn:aws:logs:") and ":log-group:" in arn
if service == "s3":
return arn.startswith("arn:aws:s3:::") and len(arn.split(":")) == 6
if service == "firehose":
return arn.startswith("arn:aws:firehose:") and ":deliverystream/" in arn
return False
def policy(resource):
# Check if WAF logging configuration exists
logging_config = resource.get("LoggingConfiguration")
if not logging_config:
return False
# Get the logging destinations
destinations = logging_config.get("LogDestinationConfigs", [])
# Validate the ARNs for CloudWatch Logs, S3, or Kinesis Firehose
for destination in destinations:
if (
is_valid_arn(destination, "logs")
or is_valid_arn(destination, "s3")
or is_valid_arn(destination, "firehose")
):
return True
return False
Rule specification
AnalysisType: policy
Filename: aws_waf_logging_configured.py
PolicyID: "AWS.WAF.LoggingConfigured"
DisplayName: "AWS WAF Logging Configured"
Enabled: true
ResourceTypes:
- AWS.WAF.Regional.WebACL
- AWS.WAF.WebACL
Tags:
- AWS
- Monitoring
- Logging
- Security Control
- Defense Evasion:Impair Defenses
Reports:
PCI:
- 10.5.5
MITRE ATT&CK:
- TA0005:T1562
Severity: High
Description: >
Ensures that AWS WAF logging is enabled and that the logs are being sent to a valid destination (S3, CloudWatch, or Kinesis Firehose). Without logging, visibility into WAF activity is severely limited, increasing the risk of undetected attacks.
Runbook: >
Ensure AWS WAF logging is configured to at least one valid destination such as an Amazon S3 bucket, Amazon CloudWatch Logs, or Amazon Kinesis Data Firehose. Refer to the AWS WAF logging documentation for setup instructions.
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/logging.html
Stages and Predicates
Flags AWS.WAF.Regional.WebACL, AWS.WAF.WebACL resources when the condition below holds.
Condition
LoggingConfigurationis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
LoggingConfiguration | is_not_null | excludes:LoggingConfiguration |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
LoggingConfiguration | is_null | field:"LoggingConfiguration" kind:is_null |
Response runbook
Ensure AWS WAF logging is configured to at least one valid destination such as an Amazon S3 bucket, Amazon CloudWatch Logs, or Amazon Kinesis Data Firehose. Refer to the AWS WAF logging documentation for setup instructions.
AWS WAF Managed Admin Protection Passthrough Rule
#Detects AWS WAF Admin Protection managed rule group matches. Blocks external access to exposed administrative pages such as /admin, /wp-admin, and similar paths.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Discovery |
Detection logic
from panther_aws_helpers import (
waf_alert_context,
waf_get_matched_rule,
waf_rule_group_matches,
waf_severity,
)
RULE_GROUP = "AWSManagedRulesAdminProtectionRuleSet"
def rule(event):
return waf_rule_group_matches(event, RULE_GROUP)
def title(event):
matched = waf_get_matched_rule(event, RULE_GROUP)
client_ip = event.deep_get("httpRequest", "clientIp", default="<UNKNOWN_CLIENT_IP>")
action = event.get("action", default="<UNKNOWN_ACTION>")
source = event.get("httpSourceName", default="<UNKNOWN_SOURCE>")
return f"AWS WAF Admin Protection: {matched} - {action} from {client_ip} via {source}"
def alert_context(event):
return waf_alert_context(event, RULE_GROUP)
def severity(event):
return waf_severity(event)
Rule specification
AnalysisType: rule
Filename: aws_waf_managed_admin_protection.py
RuleID: "AWS.WAF.Managed.AdminProtection"
DisplayName: "AWS WAF Managed Admin Protection Passthrough Rule"
Enabled: true
LogTypes:
- AWS.WAFWebACL
Tags:
- AWS
- WAF
- Managed Rules
- Initial Access:Exploit Public-Facing Application
Reports:
MITRE ATT&CK:
- TA0001:T1190
- TA0007:T1083
Severity: High
Description: >
Detects AWS WAF Admin Protection managed rule group matches. Blocks external access to exposed
administrative pages such as /admin, /wp-admin, and similar paths.
Runbook: |
1. Find all requests from httpRequest:clientIp targeting admin-related URI paths in the 6 hours before and after this alert
2. Check if httpRequest:clientIp is associated with known corporate network ranges, VPNs, or threat intelligence feeds
3. Search for successful authentication events or other WAF alerts from httpRequest:clientIp across all httpSourceIds in the past 24 hours
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-baseline.html#aws-managed-rule-groups-baseline-admin
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.WAFWebACL events when the condition below holds.
Condition
any element of
ruleGroupListmatches:any of:
ruleGroupList.terminatingRule.ruleIdis presentany element of
ruleGroupList.nonTerminatingMatchingRulesmatches:ruleGroupList.nonTerminatingMatchingRules.ruleIdis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
matched_rule | terminatingRuleId |
client_ip | httpRequest.clientIp |
country | httpRequest.country |
http_method | httpRequest.httpMethod |
uri | httpRequest.uri |
action | |
source | httpSourceName |
source_id | httpSourceId |
terminating_rule_id | terminatingRuleId |
terminating_rule_type | terminatingRuleType |
Response runbook
1. Find all requests from httpRequest:clientIp targeting admin-related URI paths in the 6 hours before and after this alert
2. Check if httpRequest:clientIp is associated with known corporate network ranges, VPNs, or threat intelligence feeds
3. Search for successful authentication events or other WAF alerts from httpRequest:clientIp across all httpSourceIds in the past 24 hours
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"action": "BLOCK",
"httpRequest": {
"clientIp": "203.0.113.45",
"country": "US",
"httpMethod": "GET",
"uri": "/wp-admin/"
},
"httpSourceName": "ALB",
"terminatingRuleId": "AWS-AWSManagedRulesAdminProtectionRuleSet",
"terminatingRuleType": "MANAGED_RULE_GROUP",
"timestamp": "2024-03-20T10:30:00.000Z",
"webaclId": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/a1b2c3d4"
}
AWS WAF Managed Anti-DDoS Passthrough Rule
#Detects AWS WAF Anti-DDoS managed rule group matches. Rules include ChallengeAllDuringEvent, ChallengeDDoSRequests, and DDoSRequests which activate during detected DDoS events to challenge or block suspicious traffic to protected resources.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
from panther_aws_helpers import (
waf_alert_context,
waf_get_matched_rule,
waf_rule_group_matches,
waf_severity,
)
RULE_GROUP = "AWSManagedRulesAntiDDoSRuleSet"
def rule(event):
return waf_rule_group_matches(event, RULE_GROUP)
def title(event):
matched = waf_get_matched_rule(event, RULE_GROUP)
client_ip = event.deep_get("httpRequest", "clientIp", default="<UNKNOWN_CLIENT_IP>")
action = event.get("action", default="<UNKNOWN_ACTION>")
source = event.get("httpSourceName", default="<UNKNOWN_SOURCE>")
return f"AWS WAF Anti-DDoS: {matched} - {action} from {client_ip} via {source}"
def alert_context(event):
return waf_alert_context(event, RULE_GROUP)
def severity(event):
return waf_severity(event)
Rule specification
AnalysisType: rule
Filename: aws_waf_managed_anti_ddos.py
RuleID: "AWS.WAF.Managed.AntiDDoS"
DisplayName: "AWS WAF Managed Anti-DDoS Passthrough Rule"
Enabled: true
LogTypes:
- AWS.WAFWebACL
Tags:
- AWS
- WAF
- Managed Rules
- DDoS
- Impact:Endpoint Denial of Service
Reports:
MITRE ATT&CK:
- TA0040:T1499
Severity: Low
Description: >
Detects AWS WAF Anti-DDoS managed rule group matches. Rules include ChallengeAllDuringEvent,
ChallengeDDoSRequests, and DDoSRequests which activate during detected DDoS events to challenge
or block suspicious traffic to protected resources.
Runbook: |
1. Find all requests from httpRequest:clientIp and its /24 CIDR range in the 1 hour before and after this alert to assess traffic volume and patterns
2. Correlate with AWS Shield Advanced events and other anti-DDoS alerts targeting the same httpSourceId in the past 6 hours to determine if this is part of an active DDoS event
3. Check if httpRequest:clientIp appears in threat intelligence feeds associated with known DDoS botnets or attack tools
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-anti-ddos.html
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.WAFWebACL events when the condition below holds.
Condition
any element of
ruleGroupListmatches:any of:
ruleGroupList.terminatingRule.ruleIdis presentany element of
ruleGroupList.nonTerminatingMatchingRulesmatches:ruleGroupList.nonTerminatingMatchingRules.ruleIdis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
matched_rule | terminatingRuleId |
client_ip | httpRequest.clientIp |
country | httpRequest.country |
http_method | httpRequest.httpMethod |
uri | httpRequest.uri |
action | |
source | httpSourceName |
source_id | httpSourceId |
terminating_rule_id | terminatingRuleId |
terminating_rule_type | terminatingRuleType |
Response runbook
1. Find all requests from httpRequest:clientIp and its /24 CIDR range in the 1 hour before and after this alert to assess traffic volume and patterns
2. Correlate with AWS Shield Advanced events and other anti-DDoS alerts targeting the same httpSourceId in the past 6 hours to determine if this is part of an active DDoS event
3. Check if httpRequest:clientIp appears in threat intelligence feeds associated with known DDoS botnets or attack tools
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"action": "BLOCK",
"httpRequest": {
"clientIp": "203.0.113.45",
"country": "US",
"httpMethod": "GET",
"uri": "/"
},
"httpSourceName": "ALB",
"terminatingRuleId": "AWS-AWSManagedRulesAntiDDoSRuleSet",
"terminatingRuleType": "MANAGED_RULE_GROUP",
"timestamp": "2024-03-20T10:30:00.000Z",
"webaclId": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/a1b2c3d4"
}
AWS WAF Managed Bot Control Passthrough Rule
#Detects AWS WAF Bot Control managed rule group matches. Covers automated browser detection, HTTP library user agents, scraping frameworks, known bot data centers, and targeted bot protections including token abuse and coordinated activity.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Reconnaissance |
Detection logic
from panther_aws_helpers import (
waf_alert_context,
waf_get_matched_rule,
waf_rule_group_matches,
waf_severity,
)
RULE_GROUP = "AWSManagedRulesBotControlRuleSet"
def rule(event):
return waf_rule_group_matches(event, RULE_GROUP)
def title(event):
matched = waf_get_matched_rule(event, RULE_GROUP)
client_ip = event.deep_get("httpRequest", "clientIp", default="<UNKNOWN_CLIENT_IP>")
action = event.get("action", default="<UNKNOWN_ACTION>")
source = event.get("httpSourceName", default="<UNKNOWN_SOURCE>")
return f"AWS WAF Bot Control: {matched} - {action} from {client_ip} via {source}"
def alert_context(event):
return waf_alert_context(event, RULE_GROUP)
def severity(event):
return waf_severity(event)
Rule specification
AnalysisType: rule
Filename: aws_waf_managed_bot_control.py
RuleID: "AWS.WAF.Managed.BotControl"
DisplayName: "AWS WAF Managed Bot Control Passthrough Rule"
Enabled: true
LogTypes:
- AWS.WAFWebACL
Tags:
- AWS
- WAF
- Managed Rules
- Bot Detection
- Reconnaissance
Reports:
MITRE ATT&CK:
- TA0043:T1595
Severity: Info
Description: >
Detects AWS WAF Bot Control managed rule group matches. Covers automated browser detection,
HTTP library user agents, scraping frameworks, known bot data centers, and targeted bot
protections including token abuse and coordinated activity.
Runbook: |
1. Find all requests from httpRequest:clientIp in the 24 hours before and after this alert to determine request volume and targeted URI patterns
2. Check if the user agent string and httpRequest:clientIp are associated with legitimate bot services (search engines, monitoring) or known malicious automation
3. Search for other WAF bot control alerts from the same httpRequest:clientIp or user agent in the past 7 days to identify persistent automated activity
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-bot.html
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.WAFWebACL events when the condition below holds.
Condition
any element of
ruleGroupListmatches:any of:
ruleGroupList.terminatingRule.ruleIdis presentany element of
ruleGroupList.nonTerminatingMatchingRulesmatches:ruleGroupList.nonTerminatingMatchingRules.ruleIdis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
matched_rule | terminatingRuleId |
client_ip | httpRequest.clientIp |
country | httpRequest.country |
http_method | httpRequest.httpMethod |
uri | httpRequest.uri |
action | |
source | httpSourceName |
source_id | httpSourceId |
terminating_rule_id | terminatingRuleId |
terminating_rule_type | terminatingRuleType |
Response runbook
1. Find all requests from httpRequest:clientIp in the 24 hours before and after this alert to determine request volume and targeted URI patterns
2. Check if the user agent string and httpRequest:clientIp are associated with legitimate bot services (search engines, monitoring) or known malicious automation
3. Search for other WAF bot control alerts from the same httpRequest:clientIp or user agent in the past 7 days to identify persistent automated activity
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"action": "BLOCK",
"httpRequest": {
"clientIp": "203.0.113.45",
"country": "US",
"httpMethod": "GET",
"uri": "/api/data"
},
"httpSourceName": "ALB",
"terminatingRuleId": "AWS-AWSManagedRulesBotControlRuleSet",
"terminatingRuleType": "MANAGED_RULE_GROUP",
"timestamp": "2024-03-20T10:30:00.000Z",
"webaclId": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/a1b2c3d4"
}
AWS WAF Managed Core Rule Set Passthrough Rule
#Detects AWS WAF Core Rule Set (CRS) managed rule group matches. Covers XSS, LFI, RFI, SSRF, size restrictions, restricted extensions, and bad bot user agents across all WAF sources.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
from panther_aws_helpers import (
waf_alert_context,
waf_get_matched_rule,
waf_rule_group_matches,
waf_severity,
)
RULE_GROUP = "AWSManagedRulesCommonRuleSet"
def rule(event):
return waf_rule_group_matches(event, RULE_GROUP)
def title(event):
matched = waf_get_matched_rule(event, RULE_GROUP)
client_ip = event.deep_get("httpRequest", "clientIp", default="<UNKNOWN_CLIENT_IP>")
action = event.get("action", default="<UNKNOWN_ACTION>")
source = event.get("httpSourceName", default="<UNKNOWN_SOURCE>")
return f"AWS WAF Core Rule Set: {matched} - {action} from {client_ip} via {source}"
def alert_context(event):
return waf_alert_context(event, RULE_GROUP)
def severity(event):
return waf_severity(event)
Rule specification
AnalysisType: rule
Filename: aws_waf_managed_core_rule_set.py
RuleID: "AWS.WAF.Managed.CoreRuleSet"
DisplayName: "AWS WAF Managed Core Rule Set Passthrough Rule"
Enabled: true
LogTypes:
- AWS.WAFWebACL
Tags:
- AWS
- WAF
- Managed Rules
- Initial Access:Exploit Public-Facing Application
Reports:
MITRE ATT&CK:
- TA0001:T1190
Severity: Medium
Description: >
Detects AWS WAF Core Rule Set (CRS) managed rule group matches. Covers XSS, LFI, RFI, SSRF,
size restrictions, restricted extensions, and bad bot user agents across all WAF sources.
Runbook: |
1. Find all WAF log entries from httpRequest:clientIp in the 6 hours before and after this alert to identify attack patterns or scanning behavior
2. Check if httpRequest:clientIp appears in threat intelligence feeds or is associated with known proxy/VPN services
3. Search for other WAF alerts targeting the same httpRequest:uri or httpSourceId in the past 7 days to determine if this is part of a broader campaign
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-baseline.html#aws-managed-rule-groups-baseline-crs
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.WAFWebACL events when the condition below holds.
Condition
any element of
ruleGroupListmatches:any of:
ruleGroupList.terminatingRule.ruleIdis presentany element of
ruleGroupList.nonTerminatingMatchingRulesmatches:ruleGroupList.nonTerminatingMatchingRules.ruleIdis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
matched_rule | terminatingRuleId |
client_ip | httpRequest.clientIp |
country | httpRequest.country |
http_method | httpRequest.httpMethod |
uri | httpRequest.uri |
action | |
source | httpSourceName |
source_id | httpSourceId |
terminating_rule_id | terminatingRuleId |
terminating_rule_type | terminatingRuleType |
Response runbook
1. Find all WAF log entries from httpRequest:clientIp in the 6 hours before and after this alert to identify attack patterns or scanning behavior
2. Check if httpRequest:clientIp appears in threat intelligence feeds or is associated with known proxy/VPN services
3. Search for other WAF alerts targeting the same httpRequest:uri or httpSourceId in the past 7 days to determine if this is part of a broader campaign
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"action": "BLOCK",
"httpRequest": {
"clientIp": "203.0.113.45",
"country": "US",
"httpMethod": "POST",
"uri": "/api/endpoint"
},
"httpSourceName": "ALB",
"terminatingRuleId": "AWS-AWSManagedRulesCommonRuleSet",
"terminatingRuleType": "MANAGED_RULE_GROUP",
"timestamp": "2024-03-20T10:30:00.000Z",
"webaclId": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/a1b2c3d4"
}
AWS WAF Managed IP Reputation Passthrough Rule
#Detects AWS WAF IP Reputation and Anonymous IP List managed rule group matches. Flags requests from IPs on Amazon threat intelligence lists including known bots, reconnaissance sources, DDoS participants, TOR nodes, temporary proxies, and hosting/cloud provider IPs.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Reconnaissance | |
| Initial Access | |
| Command & Control |
Detection logic
import re
from panther_aws_helpers import waf_alert_context, waf_get_matched_rule, waf_rule_group_matches
STATIC_ASSET_PATTERN = re.compile(
r"\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|ico|map)$", re.IGNORECASE
)
RULE_GROUPS = [
"AWSManagedRulesAmazonIpReputationList",
"AWSManagedRulesAnonymousIpList",
]
def rule(event):
if not waf_rule_group_matches(event, RULE_GROUPS):
return False
# Skip requests to static assets
uri = event.deep_get("httpRequest", "uri", default="")
if STATIC_ASSET_PATTERN.search(uri):
return False
return True
def title(event):
matched = waf_get_matched_rule(event, RULE_GROUPS)
client_ip = event.deep_get("httpRequest", "clientIp", default="<UNKNOWN_CLIENT_IP>")
action = event.get("action", default="<UNKNOWN_ACTION>")
source = event.get("httpSourceName", default="<UNKNOWN_SOURCE>")
return f"AWS WAF IP Reputation: {matched} - {action} from {client_ip} via {source}"
def alert_context(event):
return waf_alert_context(event, RULE_GROUPS)
Rule specification
AnalysisType: rule
Filename: aws_waf_managed_ip_reputation.py
RuleID: "AWS.WAF.Managed.IPReputation"
DisplayName: "AWS WAF Managed IP Reputation Passthrough Rule"
Enabled: true
LogTypes:
- AWS.WAFWebACL
Tags:
- AWS
- WAF
- Managed Rules
- Initial Access:Exploit Public-Facing Application
- Reconnaissance
- Defense Evasion
Reports:
MITRE ATT&CK:
- TA0001:T1190
- TA0043:T1595
- TA0005:T1090
Severity: Info
Description: >
Detects AWS WAF IP Reputation and Anonymous IP List managed rule group matches. Flags requests
from IPs on Amazon threat intelligence lists including known bots, reconnaissance sources, DDoS
participants, TOR nodes, temporary proxies, and hosting/cloud provider IPs.
Runbook: |
1. Find all requests from httpRequest:clientIp across all WAF sources in the 24 hours before and after this alert to assess the scope of activity
2. Check if httpRequest:clientIp is associated with known threat actors, botnets, or DDoS infrastructure in threat intelligence feeds
3. Search for other alerts from httpRequest:clientIp across all detection rules in the past 7 days to identify correlated malicious behavior
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-ip-rep.html
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.WAFWebACL events when the condition below holds.
Condition
any element of
ruleGroupListmatches:any of:
ruleGroupList.terminatingRule.ruleIdis presentany element of
ruleGroupList.nonTerminatingMatchingRulesmatches:ruleGroupList.nonTerminatingMatchingRules.ruleIdis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
matched_rule | terminatingRuleId |
client_ip | httpRequest.clientIp |
country | httpRequest.country |
http_method | httpRequest.httpMethod |
uri | httpRequest.uri |
action | |
source | httpSourceName |
source_id | httpSourceId |
terminating_rule_id | terminatingRuleId |
terminating_rule_type | terminatingRuleType |
Response runbook
1. Find all requests from httpRequest:clientIp across all WAF sources in the 24 hours before and after this alert to assess the scope of activity
2. Check if httpRequest:clientIp is associated with known threat actors, botnets, or DDoS infrastructure in threat intelligence feeds
3. Search for other alerts from httpRequest:clientIp across all detection rules in the past 7 days to identify correlated malicious behavior
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"action": "BLOCK",
"httpRequest": {
"clientIp": "203.0.113.45",
"country": "US",
"httpMethod": "GET",
"uri": "/"
},
"httpSourceName": "ALB",
"terminatingRuleId": "AWS-AWSManagedRulesAmazonIpReputationList",
"terminatingRuleType": "MANAGED_RULE_GROUP",
"timestamp": "2024-03-20T10:30:00.000Z",
"webaclId": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/a1b2c3d4"
}
AWS WAF Managed Known Bad Inputs Passthrough Rule
#Detects AWS WAF Known Bad Inputs managed rule group matches. Covers Log4Shell (CVE-2021-44228), Java deserialization RCE, localhost Host header abuse, PROPFIND method, and exploitable paths.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Execution |
Detection logic
from panther_aws_helpers import (
waf_alert_context,
waf_get_matched_rule,
waf_rule_group_matches,
waf_severity,
)
RULE_GROUP = "AWSManagedRulesKnownBadInputsRuleSet"
def rule(event):
return waf_rule_group_matches(event, RULE_GROUP)
def title(event):
matched = waf_get_matched_rule(event, RULE_GROUP)
client_ip = event.deep_get("httpRequest", "clientIp", default="<UNKNOWN_CLIENT_IP>")
action = event.get("action", default="<UNKNOWN_ACTION>")
source = event.get("httpSourceName", default="<UNKNOWN_SOURCE>")
return f"AWS WAF Known Bad Inputs: {matched} - {action} from {client_ip} via {source}"
def alert_context(event):
return waf_alert_context(event, RULE_GROUP)
def severity(event):
return waf_severity(event)
Rule specification
AnalysisType: rule
Filename: aws_waf_managed_known_bad_inputs.py
RuleID: "AWS.WAF.Managed.KnownBadInputs"
DisplayName: "AWS WAF Managed Known Bad Inputs Passthrough Rule"
Enabled: true
LogTypes:
- AWS.WAFWebACL
Tags:
- AWS
- WAF
- Managed Rules
- Initial Access:Exploit Public-Facing Application
- Execution:Command and Scripting Interpreter
Reports:
MITRE ATT&CK:
- TA0001:T1190
- TA0002:T1059
Severity: High
Description: >
Detects AWS WAF Known Bad Inputs managed rule group matches. Covers Log4Shell (CVE-2021-44228),
Java deserialization RCE, localhost Host header abuse, PROPFIND method, and exploitable paths.
Runbook: |
1. Find all WAF log entries from httpRequest:clientIp in the 6 hours before and after this alert to identify exploitation attempts across multiple endpoints
2. Check if httpRequest:clientIp appears in threat intelligence feeds or is associated with known exploit infrastructure
3. If the action was ALLOW, search application and backend server logs for the targeted httpRequest:uri in the 1 hour after the alert to determine if exploitation was successful
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-baseline.html#aws-managed-rule-groups-baseline-known-bad-inputs
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.WAFWebACL events when the condition below holds.
Condition
any element of
ruleGroupListmatches:any of:
ruleGroupList.terminatingRule.ruleIdis presentany element of
ruleGroupList.nonTerminatingMatchingRulesmatches:ruleGroupList.nonTerminatingMatchingRules.ruleIdis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
matched_rule | terminatingRuleId |
client_ip | httpRequest.clientIp |
country | httpRequest.country |
http_method | httpRequest.httpMethod |
uri | httpRequest.uri |
action | |
source | httpSourceName |
source_id | httpSourceId |
terminating_rule_id | terminatingRuleId |
terminating_rule_type | terminatingRuleType |
Response runbook
1. Find all WAF log entries from httpRequest:clientIp in the 6 hours before and after this alert to identify exploitation attempts across multiple endpoints
2. Check if httpRequest:clientIp appears in threat intelligence feeds or is associated with known exploit infrastructure
3. If the action was ALLOW, search application and backend server logs for the targeted httpRequest:uri in the 1 hour after the alert to determine if exploitation was successful
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"action": "BLOCK",
"httpRequest": {
"clientIp": "203.0.113.45",
"country": "US",
"httpMethod": "POST",
"uri": "/api/endpoint"
},
"httpSourceName": "ALB",
"terminatingRuleId": "AWS-AWSManagedRulesKnownBadInputsRuleSet",
"terminatingRuleType": "MANAGED_RULE_GROUP",
"timestamp": "2024-03-20T10:30:00.000Z",
"webaclId": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/a1b2c3d4"
}
AWS WAF Managed SQL Database Passthrough Rule
#Detects AWS WAF SQL Database managed rule group matches. Covers SQL injection patterns in query arguments, request body, cookies, and URI path, including extended patterns not covered by the Core Rule Set.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Detection logic
from panther_aws_helpers import (
waf_alert_context,
waf_get_matched_rule,
waf_rule_group_matches,
waf_severity,
)
RULE_GROUP = "AWSManagedRulesSQLiRuleSet"
def rule(event):
return waf_rule_group_matches(event, RULE_GROUP)
def title(event):
matched = waf_get_matched_rule(event, RULE_GROUP)
client_ip = event.deep_get("httpRequest", "clientIp", default="<UNKNOWN_CLIENT_IP>")
action = event.get("action", default="<UNKNOWN_ACTION>")
source = event.get("httpSourceName", default="<UNKNOWN_SOURCE>")
return f"AWS WAF SQL Database: {matched} - {action} from {client_ip} via {source}"
def alert_context(event):
return waf_alert_context(event, RULE_GROUP)
def severity(event):
return waf_severity(event)
Rule specification
AnalysisType: rule
Filename: aws_waf_managed_sql_database.py
RuleID: "AWS.WAF.Managed.SQLDatabase"
DisplayName: "AWS WAF Managed SQL Database Passthrough Rule"
Enabled: true
LogTypes:
- AWS.WAFWebACL
Tags:
- AWS
- WAF
- Managed Rules
- Initial Access:Exploit Public-Facing Application
Reports:
MITRE ATT&CK:
- TA0001:T1190
Severity: High
Description: >
Detects AWS WAF SQL Database managed rule group matches. Covers SQL injection patterns in
query arguments, request body, cookies, and URI path, including extended patterns not covered
by the Core Rule Set.
Runbook: |
1. Find all WAF log entries from httpRequest:clientIp in the 6 hours before and after this alert to identify SQL injection attempts across multiple endpoints
2. Check if httpRequest:clientIp appears in threat intelligence feeds or is associated with known automated scanning tools
3. If the action was ALLOW, search application and database logs for the targeted httpRequest:uri in the 1 hour after the alert to determine if injection was successful
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-use-case.html
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.WAFWebACL events when the condition below holds.
Condition
any element of
ruleGroupListmatches:any of:
ruleGroupList.terminatingRule.ruleIdis presentany element of
ruleGroupList.nonTerminatingMatchingRulesmatches:ruleGroupList.nonTerminatingMatchingRules.ruleIdis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
matched_rule | terminatingRuleId |
client_ip | httpRequest.clientIp |
country | httpRequest.country |
http_method | httpRequest.httpMethod |
uri | httpRequest.uri |
action | |
source | httpSourceName |
source_id | httpSourceId |
terminating_rule_id | terminatingRuleId |
terminating_rule_type | terminatingRuleType |
Response runbook
1. Find all WAF log entries from httpRequest:clientIp in the 6 hours before and after this alert to identify SQL injection attempts across multiple endpoints
2. Check if httpRequest:clientIp appears in threat intelligence feeds or is associated with known automated scanning tools
3. If the action was ALLOW, search application and database logs for the targeted httpRequest:uri in the 1 hour after the alert to determine if injection was successful
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"action": "BLOCK",
"httpRequest": {
"clientIp": "203.0.113.45",
"country": "US",
"httpMethod": "GET",
"uri": "/api/search"
},
"httpSourceName": "ALB",
"terminatingRuleId": "AWS-AWSManagedRulesSQLiRuleSet",
"terminatingRuleType": "MANAGED_RULE_GROUP",
"timestamp": "2024-03-20T10:30:00.000Z",
"webaclId": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/a1b2c3d4"
}
AWS WAF ReactJS RCE Attempt via Body
#Detects AWS WAF ReactJSRCE_BODY managed rule matches indicating React2Shell (CVE-2025-55182) ReactJS RCE attempts via HTTP body. Monitors all WAF sources: ALB, CloudFront, API Gateway, AppSync.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Execution |
Detection logic
RULE_ID = "ReactJSRCE_BODY"
def rule(event):
# Direct check of terminating rule ID
if RULE_ID in event.get("terminatingRuleId", ""):
return True
# Check non-terminating rules
for matching_rule in event.get("nonTerminatingMatchingRules", []) or []:
if RULE_ID in matching_rule.get("ruleId", ""):
return True
# Check rule groups
for group in event.get("ruleGroupList", []) or []:
terminating = group.get("terminatingRule") or {}
if RULE_ID in terminating.get("ruleId", ""):
return True
for matching_rule in group.get("nonTerminatingMatchingRules", []) or []:
if RULE_ID in matching_rule.get("ruleId", ""):
return True
return False
def title(event):
client_ip = event.deep_get("httpRequest", "clientIp", default="<UNKNOWN_CLIENT_IP>")
action = event.get("action", default="<UNKNOWN_ACTION>")
source = event.get("httpSourceName", default="<UNKNOWN_SOURCE>")
return f"AWS WAF {RULE_ID} Match - {action} from {client_ip} via {source}"
def alert_context(event):
http_request = event.get("httpRequest", {})
headers = http_request.get("headers", [])
user_agent = next(
(h.get("value") for h in headers if h.get("name", "").lower() == "user-agent"), None
)
context = {
"client_ip": http_request.get("clientIp"),
"country": http_request.get("country"),
"http_method": http_request.get("httpMethod"),
"uri": http_request.get("uri"),
"user_agent": user_agent,
"action": event.get("action"),
"source": event.get("httpSourceName"),
"source_id": event.get("httpSourceId"),
"terminating_rule_id": event.get("terminatingRuleId"),
"terminating_rule_type": event.get("terminatingRuleType"),
}
# Add matched data if available
terminating_matches = event.get("terminatingRuleMatchDetails", [])
if terminating_matches:
context["matched_data"] = [
{
"condition_type": m.get("conditionType"),
"location": m.get("location"),
"matched_strings": m.get("matchedData", []),
}
for m in terminating_matches
]
return context
def severity(event):
action = event.get("action", "")
if action == "ALLOW":
return "CRITICAL"
if action == "BLOCK":
return "HIGH"
if action == "COUNT":
return "MEDIUM"
return "DEFAULT"
Rule specification
AnalysisType: rule
Filename: aws_waf_reactjsrce_body.py
RuleID: "AWS.WAF.ReactJSRCE.Body"
DisplayName: "AWS WAF ReactJS RCE Attempt via Body"
Enabled: true
LogTypes:
- AWS.WAFWebACL
Tags:
- AWS
- WAF
- React2Shell
- Initial Access:Exploit Public-Facing Application
- Execution:Command and Scripting Interpreter
Reports:
MITRE ATT&CK:
- TA0001:T1190
- TA0002:T1059
Severity: High
Description: >
Detects AWS WAF ReactJSRCE_BODY managed rule matches indicating React2Shell (CVE-2025-55182)
ReactJS RCE attempts via HTTP body. Monitors all WAF sources: ALB, CloudFront, API Gateway, AppSync.
Runbook: |
1. Review alert context for source IP, URI, and body content
2. Check action: BLOCK (HIGH) or ALLOW (CRITICAL - investigate immediately)
3. If allowed, check target application for compromise
4. Review backend server logs for suspicious activity
5. Block repeat offender IPs and validate ReactJS input handling
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-list.html
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.WAFWebACL events when any of the conditions below holds.
Condition
any of:
terminatingRuleIdcontainsReactJSRCE_BODYany element of
nonTerminatingMatchingRulesmatches:nonTerminatingMatchingRules.ruleIdcontainsReactJSRCE_BODY
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
terminatingRuleId | contains |
| field:"terminatingRuleId" kind:contains value:"ReactJSRCE_BODY" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
client_ip | httpRequest.clientIp |
country | httpRequest.country |
http_method | httpRequest.httpMethod |
uri | httpRequest.uri |
action | |
source | httpSourceName |
source_id | httpSourceId |
terminating_rule_id | terminatingRuleId |
terminating_rule_type | terminatingRuleType |
Response runbook
1. Review alert context for source IP, URI, and body content
2. Check action: BLOCK (HIGH) or ALLOW (CRITICAL - investigate immediately)
3. If allowed, check target application for compromise
4. Review backend server logs for suspicious activity
5. Block repeat offender IPs and validate ReactJS input handling
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"action": "BLOCK",
"httpRequest": {
"clientIp": "203.0.113.45",
"country": "US",
"httpMethod": "POST",
"uri": "/api/endpoint"
},
"httpSourceName": "ALB",
"terminatingRuleId": "ReactJSRCE_BODY",
"timestamp": "2024-03-20T10:30:00.000Z",
"webaclId": "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test/a1b2c3d4"
}
AWS WAF Rule Ordering
#This policy validates that all WAF's have the correct rule ordering. Incorrect rule ordering could lead to less restrictive rules being matched and allowing traffic through before more restrictive rules that should have blocked the traffic.
Detection logic
# ORDERING is a dictionary that describes the required ordering of Web ACL rules for a
# given web acl ID. Map the Web ACL ID to an ordered tuple of Web ACL rule IDs
# Example usage:
# ORDERING{
# 'WebAclId-123': ('FirstRuleId', 'SecondRuleId', 'ThirdRuleId'),
# }
ORDERING = {
"EXAMPLE_WEB_ACL_ID": ("EXAMPLE_RULE_1_ID", "EXAMPLE_RULE_2_ID"),
}
def policy(resource):
# Check if Web ACL rule ordering is being enforced
if resource["WebACLId"] not in ORDERING:
return True
web_acl_rules = resource["Rules"]
# Check that the Web ACL has the correct number of rules
if len(ORDERING[resource["WebACLId"]]) != len(web_acl_rules):
return False
# Confirm that each rule is ordered correctly
for web_acl_rule in web_acl_rules:
# Rules are not necessarily listed in their priority order in the rules list.
# This determines their priority order, and offsets by one to be indexed starting at 0.
priority_order = web_acl_rule["Priority"] - 1
if web_acl_rule["RuleId"] != ORDERING[resource["WebACLId"]][priority_order]:
return False
# The rules all matched correctly, return True
return True
Rule specification
AnalysisType: policy
Filename: aws_waf_rule_ordering.py
PolicyID: "AWS.WAF.RuleOrdering"
DisplayName: "AWS WAF Rule Ordering"
Enabled: false
ResourceTypes:
- AWS.WAF.Regional.WebACL
- AWS.WAF.WebACL
Tags:
- AWS
- Configuration Required
- Security Control
Severity: High
Description: >
This policy validates that all WAF's have the correct rule ordering. Incorrect rule ordering could lead to less restrictive rules being matched and allowing traffic through before more restrictive rules that should have blocked the traffic.
Runbook: >
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-waf-has-correct-rule-ordering
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/web-acl-rules.html
Stages and Predicates
Flags AWS.WAF.Regional.WebACL, AWS.WAF.WebACL resources when the condition below holds.
Condition
WebACLIdis one ofEXAMPLE_WEB_ACL_ID
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
WebACLId | eq | EXAMPLE_WEB_ACL_ID | excludes:WebACLId field:"WebACLId" value:"EXAMPLE_WEB_ACL_ID" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
WebACLId | in |
| field:"WebACLId" kind:in value:"EXAMPLE_WEB_ACL_ID" |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-policies/aws-waf-has-correct-rule-ordering
AWS WAF WebACL Has Associated Resources
#This policy ensures that AWS WAF WebACLs are associated with at least one resource (ALB, CloudFront Distribution, or API Gateway). If a WebACL is not associated with any resources, it is inactive and not providing any protection.
Detection logic
def policy(resource):
# Check if the WebACL has any associated resources
associations = resource.get("AssociatedResources", [])
return len(associations) > 0
Rule specification
AnalysisType: policy
Filename: aws_waf_webacl_has_associated_resources.py
PolicyID: "AWS.WAF.WebACLHasAssociatedResources"
DisplayName: "AWS WAF WebACL Has Associated Resources"
Enabled: true
ResourceTypes:
- AWS.WAF.Regional.WebACL
- AWS.WAF.WebACL
Tags:
- AWS
- Security Control
- Optimization
Severity: Medium
Description: >
This policy ensures that AWS WAF WebACLs are associated with at least one resource (ALB, CloudFront Distribution, or API Gateway). If a WebACL is not associated with any resources, it is inactive and not providing any protection.
Runbook: >
Associate the WAF WebACL with at least one resource, such as an Application Load Balancer (ALB), CloudFront Distribution, or API Gateway. WebACLs that are not associated with resources do not protect any traffic.
Reference: https://docs.aws.amazon.com/waf/latest/developerguide/web-acl-associate.html
Stages and Predicates
Flags AWS.WAF.Regional.WebACL, AWS.WAF.WebACL resources when the condition below holds.
Condition
AssociatedResourceshas length of at most0
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
AssociatedResources | length_compare | 0 | excludes:AssociatedResources field:"AssociatedResources" value:"0" |
Response runbook
Associate the WAF WebACL with at least one resource, such as an Application Load Balancer (ALB), CloudFront Distribution, or API Gateway. WebACLs that are not associated with resources do not protect any traffic.
AWS.CloudTrail.UserAccessKeyAuth
#Detection logic
def rule(event):
# Only look for successes
if event.get("errorCode") or event.get("errorMessage"):
return False
# Reference: https://awsteele.com/blog/2020/09/26/aws-access-key-format.html
return event.deep_get("userIdentity", "accessKeyId", default="").startswith("AKIA")
def title(event):
arn = event.deep_get("userIdentity", "arn")
key = event.deep_get("userIdentity", "accessKeyId")
return f"User {arn} signed in with access key {key}"
def alert_context(event):
return {
"ip_accessKeyId": event.get("sourceIpAddress", "{not found}")
+ ":"
+ event.deep_get("userIdentity", "accessKeyId", default="{not found}")
}
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_useraccesskeyauth.py
RuleID: "AWS.CloudTrail.UserAccessKeyAuth"
DisplayName: "AWS.CloudTrail.UserAccessKeyAuth"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Info
DedupPeriodMinutes: 60
Threshold: 1
InlineFilters:
- All: []
CreateAlert: false
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyuserIdentity.accessKeyIdstarts withAKIA
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
userIdentity.accessKeyId | starts_with |
| field:"userIdentity.accessKeyId" kind:starts_with value:"AKIA" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
arn | userIdentity.arn |
accessKeyId | userIdentity.accessKeyId |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "4c24450d-007e-4849-9e1b-4954622dbb08",
"eventName": "GetCallerIdentity",
"eventSource": "sts.amazonaws.com",
"eventTime": "2024-06-02 20:16:22.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"p_any_actor_ids": [
"AIDASXP6SDP2AJQPWVFII"
],
"p_any_aws_account_ids": [
"187901811700"
],
"p_any_aws_arns": [
"arn:aws:iam::187901811700:user/exposed.user"
],
"p_any_ip_addresses": [
"73.252.165.138"
],
"p_any_trace_ids": [
"AKIASXP6SDP2F3JQERZ2"
],
"p_any_usernames": [
"exposed.user"
],
"p_event_time": "2024-06-02 20:16:22.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2024-06-02 20:25:54.289981899",
"p_row_id": "f26228572e3f9acd88e0cadc1fc88e0a",
"p_schema_version": 0,
"p_source_file": {
"aws_s3_bucket": "threat-research-trail-trail-bucket-0ipb5nzxam",
"aws_s3_key": "AWSLogs/187901811700/CloudTrail/us-east-1/2024/06/02/187901811700_CloudTrail_us-east-1_20240602T2020Z_12tLndRWeXi4IWKg.json.gz"
},
"p_source_id": "d0a1e235-6548-4e7f-952a-35063b304007",
"p_source_label": "threat-research-trail-us-east-1",
"readOnly": true,
"recipientAccountId": "187901811700",
"requestID": "c77ed3ee-8480-41df-98cb-9cf52ce04a1c",
"sourceIPAddress": "73.252.165.138",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "sts.us-east-1.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "aws-cli/2.15.59 md/awscrt#0.19.19 ua/2.0 os/macos#22.6.0 md/arch#arm64 lang/python#3.11.11 md/pyimpl#CPython cfg/retry-mode#standard md/installer#source md/prompt#off md/command#sts.get-caller-identity",
"userIdentity": {
"accessKeyId": "AKIASXP6SDP2F3JQERZ2",
"accountId": "187901811700",
"arn": "arn:aws:iam::187901811700:user/exposed.user",
"principalId": "AIDASXP6SDP2AJQPWVFII",
"type": "IAMUser",
"userName": "exposed.user"
}
}
CloudTrail EC2 StopInstances
#A CloudTrail instances were stopped. It makes further changes of instances possible
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return all(
[
not event.get("errorCode"),
not event.get("errorMessage"),
event.get("eventName") == "StopInstances",
]
)
def title(event):
instances = [
instance["instanceId"]
for instance in event.deep_get("requestParameters", "instancesSet", "items", default=[])
]
account = event.get("recipientAccountId")
return f"EC2 instances {instances} stopped in account {account}."
def alert_context(event):
context = aws_rule_context(event)
context["instance_ids"] = [
instance["instanceId"]
for instance in event.deep_get("requestParameters", "instancesSet", "items", default=[])
]
return context
Rule specification
AnalysisType: rule
RuleID: "AWS.EC2.StopInstances"
DisplayName: "CloudTrail EC2 StopInstances"
Enabled: true
CreateAlert: false
Filename: aws_ec2_stopinstances.py
LogTypes:
- AWS.CloudTrail
Tags:
- panther-signal
Severity: Info
Description: >
A CloudTrail instances were stopped. It makes further changes of instances possible
Reference: https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-log-file-examples.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisStopInstances
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
errorMessage | is_null | field:"aws::errorMessage" kind:is_null | |
eventName | eq |
| field:"aws::eventName" kind:eq value:"StopInstances" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "9357a8cc-a0eb-46a1-b67e-EXAMPLE19b14",
"eventName": "StopInstances",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2023-07-19T21:14:20Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "777788889999",
"requestID": "c308a950-e43e-444e-afc1-EXAMPLE73e49",
"requestParameters": {
"force": false,
"instancesSet": {
"items": [
{
"instanceId": "i-EXAMPLE56126103cb"
},
{
"instanceId": "i-EXAMPLEaff4840c22"
}
]
}
},
"responseElements": {
"instancesSet": {
"items": [
{
"currentState": {
"code": 64,
"name": "stopping"
},
"instanceId": "i-EXAMPLE56126103cb",
"previousState": {
"code": 16,
"name": "running"
}
},
{
"currentState": {
"code": 64,
"name": "stopping"
},
"instanceId": "i-EXAMPLEaff4840c22",
"previousState": {
"code": 16,
"name": "running"
}
}
]
},
"requestId": "c308a950-e43e-444e-afc1-EXAMPLE73e49"
},
"sessionCredentialFromConsole": "true",
"sourceIPAddress": "192.0.2.0",
"tlsDetails": {
"cipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"clientProvidedHostHeader": "ec2.us-east-1.amazonaws.com",
"tlsVersion": "TLSv1.2"
},
"userAgent": "aws-cli/2.13.5 Python/3.11.4 Linux/4.14.255-314-253.539.amzn2.x86_64 exec-env/CloudShell exe/x86_64.amzn.2 prompt/off command/ec2.stop-instances",
"userIdentity": {
"accessKeyId": "AKIAI44QH8DHBEXAMPLE",
"accountId": "777788889999",
"arn": "arn:aws:iam::777788889999:user/Nikki",
"principalId": "EXAMPLE6E4XEGITWATV6R",
"sessionContext": {
"attributes": {
"creationDate": "2023-07-19T21:11:57Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {},
"webIdFederationData": {}
},
"type": "IAMUser",
"userName": "Nikki"
}
}
CloudTrail Event Selectors Disabled
#A CloudTrail Trail was modified to exclude management events for 1 or more resource types.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of CloudTrail changes
CLOUDTRAIL_EDIT_SELECTORS = {"PutEventSelectors"}
def rule(event):
if not (aws_cloudtrail_success(event) and event.get("eventName") in CLOUDTRAIL_EDIT_SELECTORS):
return False
# Check if management events are included for each selector.
# deep_walk only returns a list if there's more than 1 entry in the nested array, so we must
# enforce it to be a list.
includes = event.deep_walk("requestParameters", "eventSelectors", "includeManagementEvents")
if includes is None:
includes = []
if not isinstance(includes, list):
includes = [includes]
# Return False all the management events are included, else return True and raise alert
return not all(includes)
def dedup(event):
# Merge on the CloudTrail ARN
return event.deep_get("requestParameters", "trailName", default="<UNKNOWN_NAME>")
def title(event):
return (
f"Management events have been exluded from CloudTrail [{dedup(event)}] in account "
f"[{event.get('recipientAccountId')}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_event_selectors_disabled.py
RuleID: "AWS.CloudTrail.EventSelectorsDisabled"
DisplayName: "CloudTrail Event Selectors Disabled"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 3.5
MITRE ATT&CK:
- TA0005:T1562
Stratus Red Team:
- aws.defense-evasion.cloudtrail-event-selectors
Severity: Medium
Description: >
A CloudTrail Trail was modified to exclude management events for 1 or more resource types.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-cloudtrail-modified
Reference: https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-update-a-trail-console.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofPutEventSelectors
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in value:"PutEventSelectors" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-cloudtrail-modified
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "4ca1cb25-7633-496b-8f92-6de876228c3f",
"eventName": "PutEventSelectors",
"eventSource": "cloudtrail.amazonaws.com",
"eventTime": "2024-11-25 17:51:21.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.11",
"managementEvent": true,
"p_event_time": "2024-11-25 17:51:21.000000000",
"p_log_type": "AWS.CloudTrail",
"p_parse_time": "2024-11-25 17:55:54.253083422",
"readOnly": false,
"recipientAccountId": "111122223333",
"requestID": "a8c6184a-89b1-4fc1-a6fa-324748d48b64",
"requestParameters": {
"eventSelectors": [
{
"dataResources": [
{
"type": "AWS::S3::Object",
"values": []
},
{
"type": "AWS::Lambda::Function",
"values": []
}
],
"excludeManagementEventSources": [],
"includeManagementEvents": false,
"readWriteType": "ReadOnly"
}
],
"trailName": "sample-cloudtrail-name"
},
"responseElements": {
"eventSelectors": [
{
"dataResources": [
{
"type": "AWS::S3::Object",
"values": []
},
{
"type": "AWS::Lambda::Function",
"values": []
}
],
"excludeManagementEventSources": [],
"includeManagementEvents": false,
"readWriteType": "ReadOnly"
}
],
"trailARN": "arn:aws:cloudtrail:us-west-2:111122223333:trail/sample-cloudtrail-name"
},
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "cloudtrail.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "sample-user-agent",
"userIdentity": {
"accessKeyId": "SAMPLE_ACCESS_KEY_ID",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/SampleRole/leroy.jenkins",
"principalId": "EXAMPLEPRINCIPLEID:leroy.jenkins",
"sessionContext": {
"attributes": {
"creationDate": "2024-11-25T16:53:42Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111122223333",
"arn": "arn:aws:iam::111122223333:role/aws-reserved/sso.amazonaws.com/us-west-2/SampleRole",
"principalId": "EXAMPLEPRINCIPLEID",
"type": "Role",
"userName": "SampleRole"
}
},
"type": "AssumedRole"
}
}
CloudTrail Stopped
#A CloudTrail Trail was modified.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- Account Security Configuration Changed (Panther)
- ASL AWS Defense Evasion Delete Cloudtrail (Splunk)
- ASL AWS Defense Evasion Stop Logging Cloudtrail (Splunk)
- AWS Lateral Movement from Kubernetes SA via AssumeRoleWithWebIdentity (Elastic)
- AWSCloudTrail - Changes made to AWS CloudTrail logs (Kusto)
- AWSCloudTrail - Config Service Resource Deletion Attempts (Kusto)
- AWSCloudTrail - Tampering to AWS CloudTrail logs (Kusto)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of CloudTrail changes
CLOUDTRAIL_STOP_DELETE = {
"DeleteTrail",
"StopLogging",
}
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") in CLOUDTRAIL_STOP_DELETE
def dedup(event):
# Merge on the CloudTrail ARN
return event.deep_get("requestParameters", "name", default="<UNKNOWN_NAME>")
def title(event):
return (
f"CloudTrail [{dedup(event)}] in account "
f"[{event.get('recipientAccountId')}] was stopped/deleted"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_cloudtrail_stopped.py
RuleID: "AWS.CloudTrail.Stopped"
DisplayName: "CloudTrail Stopped"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- DemoThreatHunting
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 3.5
MITRE ATT&CK:
- TA0005:T1562
Stratus Red Team:
- aws.defense-evasion.cloudtrail-delete
- aws.defense-evasion.cloudtrail-stop
Severity: Medium
Description: >
A CloudTrail Trail was modified.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-cloudtrail-modified
Reference: https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-delete-trails-console.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofDeleteTrail,StopLogging
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-cloudtrail-modified
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "StopLogging",
"eventSource": "cloudtrail.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"name": "arn:aws:cloudtrail:us-west-2:123456789012:trail/example-trail"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "Tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
CodeBuild Project made Public
#An AWS CodeBuild Project was made publicly accessible
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event UpdateProjectVisibility: Changes the public visibility for a project. |
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return (
event["eventName"] == "UpdateProjectVisibility"
and event.deep_get("requestParameters", "projectVisibility") == "PUBLIC_READ"
)
def title(event):
return (
f"AWS CodeBuild Project made Public by {event.deep_get('userIdentity', 'arn')} "
f"in account {event.deep_get('recipientAccountId')}"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_codebuild_made_public.py
RuleID: "AWS.CloudTrail.CodebuildProjectMadePublic"
DisplayName: "CodeBuild Project made Public"
Enabled: true
LogTypes:
- AWS.CloudTrail
Reports:
MITRE ATT&CK:
- TA0010:T1567
Tags:
- AWS
- Security Control
- Exfiltration:Exfiltration Over Web Service
Severity: High
Description: >
An AWS CodeBuild Project was made publicly accessible
Runbook: TBD
Reference: https://docs.aws.amazon.com/codebuild/latest/userguide/public-builds.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisUpdateProjectVisibilityrequestParameters.projectVisibilityisPUBLIC_READ
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"UpdateProjectVisibility" |
requestParameters.projectVisibility | eq |
| field:"requestParameters.projectVisibility" kind:eq value:"PUBLIC_READ" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Response runbook
TBD
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "982f8066-640d-40fb-b433-ba15e14fee40",
"eventName": "UpdateProjectVisibility",
"eventSource": "codebuild.amazonaws.com",
"eventTime": "2021-08-18T14:54:53Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "111122223333",
"requestID": "4397365f-c790-4c23-9fe6-97e13a16ea84",
"requestParameters": {
"projectArn": "arn:aws:codebuild:us-east-1:111122223333:project/testproject1234",
"projectVisibility": "PUBLIC_READ",
"resourceAccessRole": "arn:aws:iam::111122223333:role/service-role/test"
},
"responseElements": null,
"sourceIPAddress": "1.1.1.1",
"userAgent": "aws-internal/3 aws-sdk-java/1.11.1030 Linux/5.4.116-64.217.amzn2int.x86_64 OpenJDK_64-Bit_Server_VM/25.302-b08 java/1.8.0_302 vendor/Oracle_Corporation cfg/retry-mode/legacy",
"userIdentity": {
"accessKeyId": "ASIAXXXXXXXXXXXX",
"accountId": "111122223333",
"arn": "arn:aws:sts::111122223333:assumed-role/MakeStuffPublic",
"principalId": "111111111111",
"sessionContext": {
"attributes": {
"creationDate": "2021-08-18T14:54:10Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
DEPRECATED - AWS User Login Profile Modified
#An attacker with iam:UpdateLoginProfile permission on other users can change the password used to login to the AWS console. May be legitimate account administration.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence | |
| Stealth | |
| Lateral Movement |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return (
event.get("eventSource", "") == "iam.amazonaws.com"
and event.get("eventName", "") == "UpdateLoginProfile"
and not event.deep_get("requestParameters", "passwordResetRequired", default=False)
and not event.deep_get("userIdentity", "arn", default="").endswith(
f"/{event.deep_get('requestParameters', 'userName', default='')}"
)
)
def title(event):
return (
f"User [{event.deep_get('userIdentity', 'arn').split('/')[-1]}] "
f"changed the password for "
f"[{event.deep_get('requestParameters','userName')}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Description: "An attacker with iam:UpdateLoginProfile permission on other users can change the password used to login to the AWS console. May be legitimate account administration."
DisplayName: "DEPRECATED - AWS User Login Profile Modified"
Enabled: true
Filename: aws_user_login_profile_modified.py
Reports:
MITRE ATT&CK:
- TA0003:T1098
- TA0005:T1108
- TA0005:T1550
- TA0008:T1550
Stratus Red Team:
- aws.privilege-escalation.iam-update-user-login-profile
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_my-sec-creds-self-manage-pass-accesskeys-ssh.html
Severity: High
DedupPeriodMinutes: 60
LogTypes:
- AWS.CloudTrail
RuleID: "AWS.User.Login.Profile.Modified"
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisiam.amazonaws.comeventNameisUpdateLoginProfilerequestParameters.passwordResetRequiredis empty
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"UpdateLoginProfile" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"iam.amazonaws.com" |
requestParameters.passwordResetRequired | is_null | field:"requestParameters.passwordResetRequired" kind:is_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
userName | requestParameters.userName |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "1234",
"eventName": "UpdateLoginProfile",
"eventSource": "iam.amazonaws.com",
"eventTime": "2022-09-15 13:45:24",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "987654321",
"requestParameters": {
"passwordResetRequired": false,
"userName": "bob"
},
"sessionCredentialFromConsole": true,
"sourceIPAddress": "AWS Internal",
"userAgent": "AWS Internal",
"userIdentity": {
"accessKeyId": "ABC1234",
"accountId": "987654321",
"arn": "arn:aws:sts::98765432:assumed-role/IAM/alice",
"principalId": "ABCDE:alice",
"sessionContext": {
"attributes": {
"creationDate": "2022-09-15T13:36:47Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "987654321",
"arn": "arn:aws:iam::9876432:role/IAM",
"principalId": "1234ABC",
"type": "Role",
"userName": "IAM"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
Detect Reconnaissance from IAM Users
#An IAM user has a high volume of access denied API calls.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Detection logic
from ipaddress import ip_address
from panther_aws_helpers import aws_rule_context
# service/event patterns to monitor
RECON_ACTIONS = {
"dynamodb": ["List", "Describe", "Get"],
"ec2": ["Describe", "Get"],
"iam": ["List", "Get"],
"s3": ["List", "Get"],
"rds": ["Describe", "List"],
}
def rule(event):
# Filter events
if event.get("errorCode") != "AccessDenied":
return False
if event.deep_get("userIdentity", "type") != "IAMUser":
return False
# Console Activity can easily result in false positives as some pages contain a mix of
# items that a user may or may not have access to.
if event.get("userAgent").startswith("aws-internal/3"):
return False
# Validate the request came from outside of AWS
try:
ip_address(event.get("sourceIPAddress"))
except ValueError:
return False
# Pattern match this event to the recon actions
for event_source, event_patterns in RECON_ACTIONS.items():
if event.get("eventSource", "").startswith(event_source) and any(
event.get("eventName", "").startswith(event_pattern) for event_pattern in event_patterns
):
return True
return False
def dedup(event):
return event.deep_get("userIdentity", "arn")
def title(event):
user_type = event.deep_get("userIdentity", "type")
if user_type == "IAMUser":
user = event.deep_get("userIdentity", "userName")
# root user
elif user_type == "Root":
user = user_type
else:
user = "<UNKNOWN_USER>"
return (
"Reconnaissance activity denied to user "
f"[{user}] "
"in account "
f"[{event.get('recipientAccountId')}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_iam_user_recon_denied.py
RuleID: "AWS.IAMUser.ReconAccessDenied"
DisplayName: "Detect Reconnaissance from IAM Users"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Discovery:Cloud Service Discovery
Reports:
MITRE ATT&CK:
- TA0007:T1526
Severity: Info
Threshold: 15
DedupPeriodMinutes: 10
Description: An IAM user has a high volume of access denied API calls.
Runbook: Analyze the IP they came from, and other actions taken before/after.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/troubleshoot_access-denied.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- errorMessage
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeisAccessDenieduserIdentity.typeisIAMUseruserAgentdoes not start withaws-internal/3
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
userAgent | starts_with | aws-internal/3 | excludes:userAgent field:"userAgent" value:"aws-internal/3" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | eq |
| field:"aws::errorCode" kind:eq value:"AccessDenied" |
userIdentity.type | eq |
| field:"aws::userIdentity.type" kind:eq value:"IAMUser" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Analyze the IP they came from, and other actions taken before/after.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"errorCode": "AccessDenied",
"errorMessage": "User: arn:aws:iam::123456789012:user/tester is not authorized to perform: iam:GetRole on resource: arn:aws:iam::123456789012:role/FooBar",
"eventID": "1",
"eventName": "GetRole",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": null,
"responseElements": null,
"sourceIPAddress": "40.185.186.6",
"userAgent": "aws-sdk-go/1.32.7 (go1.14.6; linux; amd64) exec-env/AWS_Lambda_go1.x",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/tester",
"invokedBy": "signin.amazonaws.com",
"principalId": "1111",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
}
},
"type": "IAMUser",
"userName": "tester"
}
}
EC2 Network ACL Modified
#An EC2 Network ACL was modified.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- ASL AWS Network Access Control List Created with All Open Ports (Splunk)
- ASL AWS Network Access Control List Deleted (Splunk)
- AWS Network Access Control List Created with All Open Ports (Splunk)
- AWS Network Access Control List Deleted (Splunk)
- AWSCloudTrail - Changes to Amazon VPC settings (Kusto)
- AWSCloudTrail - Network ACL with all the open ports to a specified CIDR (Kusto)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of an EC2 Network ACL modification
EC2_NACL_MODIFIED_EVENTS = {
"CreateNetworkAcl",
"CreateNetworkAclEntry",
"DeleteNetworkAcl",
"DeleteNetworkAclEntry",
"ReplaceNetworkAclEntry",
"ReplaceNetworkAclAssociation",
}
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") in EC2_NACL_MODIFIED_EVENTS
def dedup(event):
return event.get("recipientAccountId")
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ec2_network_acl_modified.py
RuleID: "AWS.EC2.NetworkACLModified"
DisplayName: "EC2 Network ACL Modified"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 3.11
MITRE ATT&CK:
- TA0005:T1562
Severity: Info
Description: An EC2 Network ACL was modified.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-ec2-network-acl-modified
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html#nacl-tasks
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofCreateNetworkAcl,CreateNetworkAclEntry,DeleteNetworkAcl,DeleteNetworkAclEntry,ReplaceNetworkAclEntry
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-ec2-network-acl-modified
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "CreateNetworkAclEntry",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"aclProtocol": "-1",
"cidrBlock": "0.0.0.0/0",
"egress": true,
"icmpTypeCode": {},
"networkAclId": "acl-1",
"portRange": {},
"ruleAction": "allow",
"ruleNumber": 500
},
"responseElements": {
"_return": true,
"requestID": "1"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.ec2.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "tester"
}
},
"type": "AssumedRole"
}
}
EC2 Network Gateway Modified
#An EC2 Network Gateway was modified.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of an EC2 Network Gateway modification
EC2_GATEWAY_MODIFIED_EVENTS = {
"CreateCustomerGateway",
"DeleteCustomerGateway",
"AttachInternetGateway",
"CreateInternetGateway",
"DeleteInternetGateway",
"DetachInternetGateway",
}
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") in EC2_GATEWAY_MODIFIED_EVENTS
def dedup(event):
return event.get("recipientAccountId")
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ec2_gateway_modified.py
RuleID: "AWS.EC2.GatewayModified"
DisplayName: "EC2 Network Gateway Modified"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 3.12
MITRE ATT&CK:
- TA0005:T1562
Severity: Info
Description: An EC2 Network Gateway was modified.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-ec2-gateway-modified
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Internet_Gateway.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofCreateCustomerGateway,DeleteCustomerGateway,AttachInternetGateway,CreateInternetGateway,DeleteInternetGateway
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-ec2-gateway-modified
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "AttachInternetGateway",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"internetGatewayId": "igw-1",
"vpcId": "vpc-1"
},
"responseElements": {
"_return": true,
"requestID": "1"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.ec2.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "tester"
}
},
"type": "AssumedRole"
}
}
EC2 Route Table Modified
#An EC2 Route Table was modified.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Exfiltration |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of an EC2 Route Table modification
EC2_RT_MODIFIED_EVENTS = {
"CreateRoute",
"CreateRouteTable",
"ReplaceRoute",
"ReplaceRouteTableAssociation",
"DeleteRouteTable",
"DeleteRoute",
"DisassociateRouteTable",
}
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") in EC2_RT_MODIFIED_EVENTS
def dedup(event):
return event.get("recipientAccountId")
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ec2_route_table_modified.py
RuleID: "AWS.EC2.RouteTableModified"
DisplayName: "EC2 Route Table Modified"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Exfiltration:Exfiltration Over Alternative Protocol
Reports:
CIS:
- 3.13
MITRE ATT&CK:
- TA0010:T1048
Severity: Info
Description: An EC2 Route Table was modified.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-ec2-route-table-modified
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/WorkWithRouteTables.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofCreateRoute,CreateRouteTable,ReplaceRoute,ReplaceRouteTableAssociation,DeleteRouteTable
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-ec2-route-table-modified
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "CreateRoute",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"destinationCidrBlock": "0.0.0.0/0",
"gatewayId": "igw-1",
"routeTableId": "rtb-1"
},
"responseElements": {
"_return": true,
"requestID": "1"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.ec2.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "tester"
}
},
"type": "AssumedRole"
}
}
EC2 Secrets Manager Retrieve Secrets
#An attacker attempted to retrieve a high number of Secrets Manager secrets, through secretsmanager:GetSecretValue.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event GetSecretValue: Retrieves the value of a secret stored in AWS Secrets Manager. |
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
if (
event.get("eventName") == "GetSecretValue"
and not aws_cloudtrail_success(event)
and event.get("errorCode") == "AccessDenied"
):
return True
return False
def title(event):
user = event.udm("actor_user")
return f"[{user}] is not authorized to retrieve secrets from AWS Secrets Manager"
def alert_context(event):
return aws_rule_context(event) | {
"errorCode": event.get("errorCode"),
"errorMessage": event.get("errorMessage"),
}
Rule specification
AnalysisType: rule
Filename: aws_secretsmanager_retrieve_secrets.py
RuleID: "AWS.SecretsManager.RetrieveSecrets"
DisplayName: "EC2 Secrets Manager Retrieve Secrets"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Credential Access
- Stratus Red Team
Status: Experimental
Reports:
MITRE ATT&CK:
- TA0006:T1552 # Credentials from Password Stores
Severity: Info
Description: An attacker attempted to retrieve a high number of Secrets Manager secrets, through secretsmanager:GetSecretValue.
Runbook: https://permiso.io/blog/lucr-3-scattered-spider-getting-saas-y-in-the-cloud
Reference: https://stratus-red-team.cloud/attack-techniques/AWS/aws.credential-access.secretsmanager-retrieve-secrets/
Threshold: 20
DedupPeriodMinutes: 1440
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisGetSecretValueany of:
errorCodeis presenterrorMessageis present
errorCodeisAccessDenied
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | eq |
| field:"aws::errorCode" kind:eq value:"AccessDenied" |
errorCode | is_not_null | field:"aws::errorCode" kind:is_not_null | |
errorMessage | is_not_null | field:"aws::errorMessage" kind:is_not_null | |
eventName | eq |
| field:"aws::eventName" kind:eq value:"GetSecretValue" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
actor_user |
Response runbook
https://permiso.io/blog/lucr-3-scattered-spider-getting-saas-y-in-the-cloud
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"errorCode": "AccessDenied",
"eventCategory": "Management",
"eventID": "dfd6d93a-2ce6-4dbe-8939-86b8ba67c868",
"eventName": "GetSecretValue",
"eventSource": "secretsmanager.amazonaws.com",
"eventTime": "2024-10-17 21:48:45.000000000",
"eventType": "AwsApiCall",
"eventVersion": "1.09",
"managementEvent": true,
"readOnly": true,
"recipientAccountId": "123123123123",
"requestID": "5128b35f-0daf-4c5e-948a-21a1c507968c",
"requestParameters": {
"secretId": "arn:aws:secretsmanager:us-west-2:123123123123:secret:stratus-red-team-retrieve-secret-3-gscOm8",
"versionId": "7DC59E8B-63AE-454D-B7A4-8A7D64AB05E7"
},
"sourceIPAddress": "123.123.123.123",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "secretsmanager.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "APN/1.0 HashiCorp/1.0 Terraform/1.1.2 (+https://www.terraform.io) terraform-provider-aws/3.76.1 (+https://registry.terraform.io/providers/hashicorp/aws) aws-sdk-go/1.44.157 (go1.19.3; darwin; arm64) HashiCorp-terraform-exec/0.17.3",
"userIdentity": {
"accessKeyId": "ASIASXP6SDP2LKLKYYC4",
"accountId": "123123123123",
"arn": "arn:aws:sts::123123123123:assumed-role/AWSReservedSSO_DevAdmin_635426549a280cc6/evil.genius",
"principalId": "AROASXP6SDP2F4WLQVARB:evil.genius",
"sessionContext": {
"attributes": {
"creationDate": "2024-10-17T21:48:13Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "123123123123",
"arn": "arn:aws:iam::123123123123:role/aws-reserved/sso.amazonaws.com/us-west-2/AWSReservedSSO_DevAdmin_635426549a280cc6",
"principalId": "AROASXP6SDP2F4WLQVARB",
"type": "Role",
"userName": "AWSReservedSSO_DevAdmin_635426549a280cc6"
}
},
"type": "AssumedRole"
}
}
EC2 Security Group Modified
#An EC2 Security Group was modified.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of an EC2 SecurityGroup modification
EC2_SG_MODIFIED_EVENTS = {
"AuthorizeSecurityGroupIngress",
"AuthorizeSecurityGroupEgress",
"RevokeSecurityGroupIngress",
"RevokeSecurityGroupEgress",
"CreateSecurityGroup",
"DeleteSecurityGroup",
}
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") in EC2_SG_MODIFIED_EVENTS
def dedup(event):
return event.get("recipientAccountId")
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ec2_security_group_modified.py
RuleID: "AWS.EC2.SecurityGroupModified"
DisplayName: "EC2 Security Group Modified"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 3.10
MITRE ATT&CK:
- TA0005:T1562
Stratus Red Team:
- aws.exfiltration.ec2-security-group-open-port-22-ingress
Severity: Info
DedupPeriodMinutes: 720 # 12 hours
Description: >
An EC2 Security Group was modified.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-ec2-securitygroup-modified
Reference: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/working-with-security-groups.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofAuthorizeSecurityGroupIngress,AuthorizeSecurityGroupEgress,RevokeSecurityGroupIngress,RevokeSecurityGroupEgress,CreateSecurityGroup
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-ec2-securitygroup-modified
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "AuthorizeSecurityGroupIngress",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"groupId": "sg-1",
"ipPermissions": {
"items": [
{
"fromPort": 22,
"groups": {},
"ipProtocol": "tcp",
"ipRanges": {
"items": [
{
"cidrIp": "127.0.0.1/32",
"description": "SSH for me"
}
]
},
"ipv6Ranges": {},
"prefixListIds": {},
"toPort": 22
}
]
}
},
"responseElements": {
"_return": true,
"requestID": "1"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.ec2.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
EC2 VPC Modified
#An EC2 VPC was modified.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of an EC2 VPC modification
EC2_VPC_MODIFIED_EVENTS = {
"CreateVpc",
"DeleteVpc",
"ModifyVpcAttribute",
"AcceptVpcPeeringConnection",
"CreateVpcPeeringConnection",
"DeleteVpcPeeringConnection",
"RejectVpcPeeringConnection",
"AttachClassicLinkVpc",
"DetachClassicLinkVpc",
"DisableVpcClassicLink",
"EnableVpcClassicLink",
}
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") in EC2_VPC_MODIFIED_EVENTS
def dedup(event):
return event.get("recipientAccountId")
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ec2_vpc_modified.py
RuleID: "AWS.EC2.VPCModified"
DisplayName: "EC2 VPC Modified"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Defense Evasion:Impair Defenses
Reports:
CIS:
- 3.14
MITRE ATT&CK:
- TA0005:T1562
Severity: Info
DedupPeriodMinutes: 720 # 12 hours
Description: An EC2 VPC was modified.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-ec2-vpc-modified
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/configure-your-vpc.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofCreateVpc,DeleteVpc,ModifyVpcAttribute,AcceptVpcPeeringConnection,CreateVpcPeeringConnection
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-ec2-vpc-modified
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "CreateVpc",
"eventSource": "ec2.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"amazonProvidedIpv6CidrBlock": false,
"cidrBlock": "0.0.0.0/26",
"instanceTenancy": "default"
},
"responseElements": {
"requestID": "1",
"vpc": {
"cidrBlock": "0.0.0.0/26",
"cidrBlockAssociationSet": {
"items": [
{
"associationId": "vpc-cidr-assoc-1",
"cidrBlock": "0.0.0.0/26",
"cidrBlockState": {
"state": "associated"
}
}
]
},
"dhcpOptionsId": "dopt-1",
"instanceTenancy": "default",
"ipv6CidrBlockAssociationSet": {},
"isDefault": false,
"ownerId": "123456789012",
"state": "pending",
"tagSet": {},
"vpcId": "vpc-1"
}
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.ec2.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
ECR CRUD Actions
#Unauthorized ECR Create, Read, Update, or Delete event occurred.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from fnmatch import fnmatch
from panther_aws_helpers import aws_rule_context
ECR_CRUD_EVENTS = {
"BatchCheckLayerAvailability",
"BatchDeleteImage",
"BatchGetImage",
"CompleteLayerUpload",
"CreateRepository",
"DeleteRepository",
"DeleteRepositoryPolicy",
"GetAuthorizationToken",
"GetDownloadUrlForLayer",
"GetRepositoryPolicy",
"InitiateLayerUpload",
"PutImage",
"SetRepositoryPolicy",
"UploadLayerPart",
}
ALLOWED_ROLES = [
"*DeployRole",
]
def rule(event):
if (
event.get("eventSource") == "ecr.amazonaws.com"
and event.get("eventName") in ECR_CRUD_EVENTS
):
for role in ALLOWED_ROLES:
if fnmatch(event.deep_get("userIdentity", "arn", default="unknown-arn"), role):
return False
return True
return False
def title(event):
return (
f"[{event.deep_get('userIdentity','arn', default = 'unknown-arn')}] "
f"performed ECR {event.get('eventName')} in "
f"[{event.get('recipientAccountId')} {event.get('awsRegion')}]."
)
def dedup(event):
return f"{event.deep_get('userIdentity','arn', default = 'unknown-arn')}"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_ecr_crud.py
RuleID: "AWS.ECR.CRUD"
DisplayName: "ECR CRUD Actions"
Enabled: false
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Configuration Required
Reports:
CIS:
- 3.12
MITRE ATT&CK:
- TA0005:T1525
Severity: Info
CreateAlert: false
Description: Unauthorized ECR Create, Read, Update, or Delete event occurred.
Runbook: https://docs.aws.amazon.com/AmazonECR/latest/userguide/logging-using-cloudtrail.html
Reference: https://docs.aws.amazon.com/AmazonECR/latest/userguide/security-iam.html#security_iam_authentication
SummaryAttributes:
- eventSource
- eventName
- recipientAccountId
- awsRegion
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisecr.amazonaws.comeventNameis one ofBatchCheckLayerAvailability,BatchDeleteImage,BatchGetImage,CompleteLayerUpload,CreateRepositoryuserIdentity.arndoes not match the pattern*DeployRole
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
userIdentity.arn | ends_with | DeployRole | excludes:userIdentity.arn field:"userIdentity.arn" value:"DeployRole" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"ecr.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Response runbook
https://docs.aws.amazon.com/AmazonECR/latest/userguide/logging-using-cloudtrail.html
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-2",
"eventID": "2bfd4ee2-2178-4a82-a27d-b12939923f0f",
"eventName": "PutImage",
"eventSource": "ecr.amazonaws.com",
"eventTime": "2019-04-15T16:45:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.04",
"recipientAccountId": "123456789012",
"requestID": "cf044b7d-5f9d-11e9-9b2a-95983139cc57",
"requestParameters": {
"imageManifest": "{\n \"schemaVersion\": 2,\n \"mediaType\": \"application/vnd.docker.distribution.manifest.v2+json\",\n \"config\": {\n \"mediaType\": \"application/vnd.docker.container.image.v1+json\",\n \"size\": 5543,\n \"digest\": \"sha256:000b9b805af1cdb60628898c9f411996301a1c13afd3dbef1d8a16ac6dbf503a\"\n },\n \"layers\": [\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 43252507,\n \"digest\": \"sha256:3b37166ec61459e76e33282dda08f2a9cd698ca7e3d6bc44e6a6e7580cdeff8e\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 846,\n \"digest\": \"sha256:504facff238fde83f1ca8f9f54520b4219c5b8f80be9616ddc52d31448a044bd\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 615,\n \"digest\": \"sha256:ebbcacd28e101968415b0c812b2d2dc60f969e36b0b08c073bf796e12b1bb449\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 850,\n \"digest\": \"sha256:c7fb3351ecad291a88b92b600037e2435c84a347683d540042086fe72c902b8a\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 168,\n \"digest\": \"sha256:2e3debadcbf7e542e2aefbce1b64a358b1931fb403b3e4aeca27cb4d809d56c2\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 37720774,\n \"digest\": \"sha256:f8c9f51ad524d8ae9bf4db69cd3e720ba92373ec265f5c390ffb21bb0c277941\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 30432107,\n \"digest\": \"sha256:813a50b13f61cf1f8d25f19fa96ad3aa5b552896c83e86ce413b48b091d7f01b\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 197,\n \"digest\": \"sha256:7ab043301a6187ea3293d80b30ba06c7bf1a0c3cd4c43d10353b31bc0cecfe7d\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 154,\n \"digest\": \"sha256:67012cca8f31dc3b8ee2305e7762fee20c250513effdedb38a1c37784a5a2e71\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 176,\n \"digest\": \"sha256:3bc892145603fffc9b1c97c94e2985b4cb19ca508750b15845a5d97becbd1a0e\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 183,\n \"digest\": \"sha256:6f1c79518f18251d35977e7e46bfa6c6b9cf50df2a79d4194941d95c54258d18\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 212,\n \"digest\": \"sha256:b7bcfbc2e2888afebede4dd1cd5eebf029bb6315feeaf0b56e425e11a50afe42\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 212,\n \"digest\": \"sha256:2b220f8b0f32b7c2ed8eaafe1c802633bbd94849b9ab73926f0ba46cdae91629\"\n }\n ]\n}",
"imageTag": "latest",
"registryId": "123456789012",
"repositoryName": "testrepo"
},
"resources": [
{
"ARN": "arn:aws:ecr:us-east-2:123456789012:repository/testrepo",
"accountId": "123456789012"
}
],
"responseElements": {
"image": {
"imageId": {
"imageDigest": "sha256:98c8b060c21d9adbb6b8c41b916e95e6307102786973ab93a41e8b86d1fc6d3e",
"imageTag": "latest"
},
"imageManifest": "{\n \"schemaVersion\": 2,\n \"mediaType\": \"application/vnd.docker.distribution.manifest.v2+json\",\n \"config\": {\n \"mediaType\": \"application/vnd.docker.container.image.v1+json\",\n \"size\": 5543,\n \"digest\": \"sha256:000b9b805af1cdb60628898c9f411996301a1c13afd3dbef1d8a16ac6dbf503a\"\n },\n \"layers\": [\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 43252507,\n \"digest\": \"sha256:3b37166ec61459e76e33282dda08f2a9cd698ca7e3d6bc44e6a6e7580cdeff8e\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 846,\n \"digest\": \"sha256:504facff238fde83f1ca8f9f54520b4219c5b8f80be9616ddc52d31448a044bd\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 615,\n \"digest\": \"sha256:ebbcacd28e101968415b0c812b2d2dc60f969e36b0b08c073bf796e12b1bb449\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 850,\n \"digest\": \"sha256:c7fb3351ecad291a88b92b600037e2435c84a347683d540042086fe72c902b8a\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 168,\n \"digest\": \"sha256:2e3debadcbf7e542e2aefbce1b64a358b1931fb403b3e4aeca27cb4d809d56c2\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 37720774,\n \"digest\": \"sha256:f8c9f51ad524d8ae9bf4db69cd3e720ba92373ec265f5c390ffb21bb0c277941\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 30432107,\n \"digest\": \"sha256:813a50b13f61cf1f8d25f19fa96ad3aa5b552896c83e86ce413b48b091d7f01b\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 197,\n \"digest\": \"sha256:7ab043301a6187ea3293d80b30ba06c7bf1a0c3cd4c43d10353b31bc0cecfe7d\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 154,\n \"digest\": \"sha256:67012cca8f31dc3b8ee2305e7762fee20c250513effdedb38a1c37784a5a2e71\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 176,\n \"digest\": \"sha256:3bc892145603fffc9b1c97c94e2985b4cb19ca508750b15845a5d97becbd1a0e\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 183,\n \"digest\": \"sha256:6f1c79518f18251d35977e7e46bfa6c6b9cf50df2a79d4194941d95c54258d18\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 212,\n \"digest\": \"sha256:b7bcfbc2e2888afebede4dd1cd5eebf029bb6315feeaf0b56e425e11a50afe42\"\n },\n {\n \"mediaType\": \"application/vnd.docker.image.rootfs.diff.tar.gzip\",\n \"size\": 212,\n \"digest\": \"sha256:2b220f8b0f32b7c2ed8eaafe1c802633bbd94849b9ab73926f0ba46cdae91629\"\n }\n ]\n}",
"registryId": "123456789012",
"repositoryName": "testrepo"
}
},
"sourceIPAddress": "203.0.113.12",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:user/Mary_Major",
"principalId": "AIDACKCEVSQ6C2EXAMPLE:account_name",
"sessionContext": {
"attributes": {
"creationDate": "2019-04-15T16:42:14Z",
"mfaAuthenticated": "false"
}
},
"type": "IAMUser",
"userName": "Mary_Major"
}
}
External Principal Accessing AWS Resources Via VPC Endpoint
#This rule detects when a principal from one AWS account accesses resources in a different AWS account using a VPC Endpoint. While cross-account access may be expected in some cases, it could also indicate unauthorized lateral movement between AWS accounts.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment | |
| Discovery | |
| Exfiltration |
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
# Check if this is a VPC Endpoint network activity event
if event.get("eventType") != "AwsVpceEvent" or event.get("eventCategory") != "NetworkActivity":
return False
# Look for external principal pattern (limited userIdentity field)
user_identity = event.get("userIdentity", {})
# If it's an AWS account type without full identity details, it could be an external principal
if (
user_identity.get("type") == "AWSAccount"
and "arn" not in user_identity
and "principalId" in user_identity
):
# Get the account ID from the event and compare with the principal's account
event_account = event.get("recipientAccountId")
principal_account = user_identity.get("accountId")
# If the accounts don't match, it's an external principal
if event_account and principal_account and event_account != principal_account:
return True
return False
def title(event):
# Use UDM actor_user which leverages the get_actor_user helper function
# This properly handles various identity types including AssumedRole, Root, etc.
actor_user = event.udm("actor_user")
principal_account = event.deep_get("userIdentity", "accountId", default="unknown")
event_account = event.get("recipientAccountId", "unknown")
return (
f"External Principal [{actor_user}] from account [{principal_account}] "
f"accessing resources in account [{event_account}]"
)
def alert_context(event):
principal_account = event.deep_get("userIdentity", "accountId", default="")
event_account = event.get("recipientAccountId", "")
context = aws_rule_context(event)
context.update(
{
"event_account": event_account,
"principal_account": principal_account,
"principal_id": event.deep_get("userIdentity", "principalId", default="unknown"),
"source_ip": event.get("sourceIPAddress", "unknown"),
"event_source": event.get("eventSource", "unknown"),
"api_call": event.get("eventName", "unknown"),
"resources": event.get("resources", []),
"actor_user": event.udm("actor_user"),
}
)
return context
Rule specification
AnalysisType: rule
Filename: aws_vpce_external_principal.py
RuleID: AWS.CloudTrail.VPCE.ExternalPrincipal
DisplayName: External Principal Accessing AWS Resources Via VPC Endpoint
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- CloudTrail
- VPCEndpoint
- Network Boundary Bridging
- Cloud Service Discovery
- Exfiltration Over Alternative Protocol
Reports:
MITRE ATT&CK:
- TA0005:T1599 # Network Boundary Bridging
- TA0007:T1526 # Cloud Service Discovery
- TA0010:T1048 # Exfiltration Over Alternative Protocol
Severity: Medium
Description: >
This rule detects when a principal from one AWS account accesses resources in a different AWS account using a VPC Endpoint.
While cross-account access may be expected in some cases, it could also indicate unauthorized lateral movement between AWS accounts.
Runbook: |
1. Identify the principal account and the accessed account from the alert context.
2. Verify if the cross-account access is expected and authorized:
- Check if the principal account is part of your organization
- Review IAM policies for the VPC Endpoint to confirm if cross-account access is intentional
- Check resource policies for the accessed service to confirm if the principal should have access
3. If the access is unexpected:
- Review the API calls made by the principal
- Check the VPC Endpoint configuration for potential misconfiguration
- Consider restricting VPC Endpoint access to prevent unauthorized cross-account access
- Investigate for additional signs of unauthorized access
4. Document findings and take appropriate remediation steps based on investigation.
Reference: https://www.wiz.io/blog/aws-vpc-endpoint-cloudtrail
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventTypeisAwsVpceEventeventCategoryisNetworkActivityuserIdentity.typeisAWSAccountuserIdentitydoes not containarnuserIdentitycontainsprincipalIdrecipientAccountIdis presentuserIdentity.accountIdis presentrecipientAccountIddiffers from fielduserIdentity.accountId
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
eventCategory | ne | NetworkActivity | excludes:eventCategory field:"eventCategory" value:"NetworkActivity" |
eventType | ne | AwsVpceEvent | excludes:eventType field:"eventType" value:"AwsVpceEvent" |
userIdentity | contains | arn | excludes:userIdentity field:"userIdentity" value:"arn" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
recipientAccountId | cross_field_compare |
| field:"recipientAccountId" kind:cross_field_compare value:"userIdentity.accountId" |
recipientAccountId | is_not_null | field:"recipientAccountId" kind:is_not_null | |
userIdentity | contains |
| field:"userIdentity" kind:contains value:"principalId" |
userIdentity.accountId | is_not_null | field:"userIdentity.accountId" kind:is_not_null | |
userIdentity.type | eq |
| field:"aws::userIdentity.type" kind:eq value:"AWSAccount" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
accountId | userIdentity.accountId |
Response runbook
1. Identify the principal account and the accessed account from the alert context.
2. Verify if the cross-account access is expected and authorized:
- Check if the principal account is part of your organization
- Review IAM policies for the VPC Endpoint to confirm if cross-account access is intentional
- Check resource policies for the accessed service to confirm if the principal should have access
3. If the access is unexpected:
- Review the API calls made by the principal
- Check the VPC Endpoint configuration for potential misconfiguration
- Consider restricting VPC Endpoint access to prevent unauthorized cross-account access
- Investigate for additional signs of unauthorized access
4. Document findings and take appropriate remediation steps based on investigation.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "NetworkActivity",
"eventName": "GetObject",
"eventSource": "s3.amazonaws.com",
"eventTime": "2023-03-01T00:00:00Z",
"eventType": "AwsVpceEvent",
"eventVersion": "1.08",
"recipientAccountId": "222222222222",
"requestParameters": {
"bucketName": "example-bucket",
"key": "sensitive-file.txt"
},
"responseElements": null,
"sourceIPAddress": "10.0.0.1",
"userIdentity": {
"accountId": "111111111111",
"principalId": "AROAEXAMPLE:session-name",
"type": "AWSAccount"
},
"vpcEndpointAccountId": "222222222222",
"vpcEndpointId": "vpce-EXAMPLE08c1b6b9b7"
}
Failed Root Console Login
#A Root console login failed.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Credential Access |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Console Login (Panther)
- AWS Console Login Failed During MFA Challenge (Splunk)
- AWS ConsoleLogin Failed Authentication (Sigma)
- AWS CreateLoginProfile (Splunk)
- AWS Credential Access Failed Login (Splunk)
- AWS High Number Of Failed Authentications For User (Splunk)
- AWS High Number Of Failed Authentications From Ip (Splunk)
- AWS Multiple Failed MFA Requests For User (Splunk)
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
return (
event.get("eventName") == "ConsoleLogin"
and event.deep_get("userIdentity", "type") == "Root"
and event.deep_get("responseElements", "ConsoleLogin") == "Failure"
)
def title(event):
return (
f"AWS root login failed from [{event.get('sourceIPAddress')}] in account "
f"[{event.get('recipientAccountId')}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_console_root_login_failed.py
RuleID: "AWS.Console.RootLoginFailed"
DisplayName: "Failed Root Console Login"
Enabled: true
DedupPeriodMinutes: 15
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Identity & Access Management
- Authentication
- DemoThreatHunting
- Credential Access:Brute Force
Threshold: 5
Reports:
CIS:
- 3.6
MITRE ATT&CK:
- TA0006:T1110
Severity: High
Description: A Root console login failed.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-console-login-failed
Reference: https://amzn.to/3aMSmTd
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisConsoleLoginuserIdentity.typeisRootresponseElements.ConsoleLoginisFailure
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-console-login-failed
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"LoginTo": "https://console.aws.amazon.com/console/",
"MFAUsed": "No",
"MobileVersion": "No"
},
"awsRegion": "us-east-1",
"eventID": "1",
"eventName": "ConsoleLogin",
"eventSource": "signin.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsConsoleSignIn",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestParameters": null,
"responseElements": {
"ConsoleLogin": "Failure"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla",
"userIdentity": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:root",
"principalId": "1111",
"type": "Root",
"userName": "root"
}
}
IAM Administrator Role Policy Attached
#An IAM role policy was attached with Administrator Access, which could indicate a potential security risk.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
if not aws_cloudtrail_success(event) or event.get("eventName") != "AttachRolePolicy":
return False
policy = event.deep_get("requestParameters", "policyArn", default="POLICY_NOT_FOUND")
return policy.endswith("AdministratorAccess")
def alert_context(event):
context = aws_rule_context(event)
context["request_rolename"] = event.deep_get(
"requestParameters", "roleName", default="ROLENAME_NOT_FOUND"
)
return context
Rule specification
AnalysisType: rule
Filename: aws_iam_attach_admin_role_policy.py
RuleID: "AWS.IAM.AttachAdminRolePolicy"
DisplayName: "IAM Administrator Role Policy Attached"
Enabled: true
LogTypes:
- AWS.CloudTrail
CreateAlert: false
Reports:
CIS:
- 1.1
MITRE ATT&CK:
- TA0007:T1078
Severity: Info
Description: >
An IAM role policy was attached with Administrator Access, which could indicate a potential security risk.
Runbook: Check if the action was expected. If not, remove the policy from the role.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisAttachRolePolicyrequestParameters.policyArnends withAdministratorAccess
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | ne | AttachRolePolicy | excludes:eventName field:"eventName" value:"AttachRolePolicy" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
requestParameters.policyArn | ends_with |
| field:"requestParameters.policyArn" kind:ends_with value:"AdministratorAccess" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Check if the action was expected. If not, remove the policy from the role.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "AttachRolePolicy",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"policyArn": "arn:aws:iam::aws:policy/AdministratorAccess",
"roleName": "new-role"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "Tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
IAM Assume Role Blocklist Ignored
#A user assumed a role that was explicitly blocklisted for manual user assumption.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# This is a list of role ARNs that should not be assumed by users in normal operations
ASSUME_ROLE_BLOCKLIST = [
"arn:aws:iam::123456789012:role/FullAdminRole",
]
def rule(event):
# Only considering successful AssumeRole action
if not aws_cloudtrail_success(event) or event.get("eventName") != "AssumeRole":
return False
# Only considering user actions
if event.deep_get("userIdentity", "type") not in ["IAMUser", "FederatedUser"]:
return False
return event.deep_get("requestParameters", "roleArn") in ASSUME_ROLE_BLOCKLIST
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_iam_assume_role_blocklist_ignored.py
RuleID: "AWS.CloudTrail.IAMAssumeRoleBlacklistIgnored"
DisplayName: "IAM Assume Role Blocklist Ignored"
Enabled: false
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Configuration Required
- Identity and Access Management
- Privilege Escalation:Abuse Elevation Control Mechanism
Reports:
MITRE ATT&CK:
- TA0004:T1548
Severity: High
Description: >
A user assumed a role that was explicitly blocklisted for manual user assumption.
Runbook: >
Verify that this was an approved assume role action. If not, consider revoking the access immediately and updating the AssumeRolePolicyDocument to prevent this from happening again.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisAssumeRoleuserIdentity.typeis one ofIAMUser,FederatedUserrequestParameters.roleArnis one ofarn:aws:iam::123456789012:role/FullAdminRole
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | ne | AssumeRole | excludes:eventName field:"eventName" value:"AssumeRole" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
requestParameters.roleArn | in |
| field:"requestParameters.roleArn" kind:in value:"arn:aws:iam::123456789012:role/FullAdminRole" |
userIdentity.type | in |
| field:"aws::userIdentity.type" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Verify that this was an approved assume role action. If not, consider revoking the access immediately and updating the AssumeRolePolicyDocument to prevent this from happening again.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "1111",
"eventName": "AssumeRole",
"eventSource": "sts.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1111",
"requestParameters": {
"durationSeconds": 900,
"roleArn": "arn:aws:iam::123456789012:role/FullAdminRole",
"roleSessionName": "1111"
},
"resources": [
{
"ARN": "arn:aws:iam::123456789012:role/FullAdminRole",
"accountId": "123456789012",
"type": "AWS::IAM::Role"
}
],
"responseElements": {
"assumedRoleUser": {
"arn": "arn:aws:sts::123456789012:assumed-role/FullAdminRole/1111",
"assumedRoleId": "ABCD:1111"
},
"credentials": {
"accessKeyId": "1111",
"expiration": "Jan 01, 2019 0:00:00 PM",
"sessionToken": "1111"
}
},
"sharedEventID": "1111",
"sourceIPAddress": "111.111.111.111",
"userAgent": "aws-sdk-go/1.4.14 (go1.11.4; darwin; amd64)",
"userIdentity": {
"accesKeyId": "1111",
"accessKeyId": "1111",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/example-user",
"principalId": "1111",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
}
},
"type": "IAMUser",
"userName": "example-user"
}
}
IAM Change
#A change occurred in the IAM configuration. This could be a resource being created, deleted, or modified. This is a high level view of changes, helfpul to indicate how dynamic a certain IAM environment is.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
IAM_CHANGE_ACTIONS = [
"Add",
"Attach",
"Change",
"Create",
"Deactivate",
"Delete",
"Detach",
"Enable",
"Put",
"Remove",
"Set",
"Update",
"Upload",
]
def rule(event):
# Only check IAM events, as the next check is relatively computationally
# expensive and can often be skipped
if not aws_cloudtrail_success(event) or event.get("eventSource") != "iam.amazonaws.com":
return False
return any((event.get("eventName", "").startswith(action) for action in IAM_CHANGE_ACTIONS))
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_iam_anything_changed.py
RuleID: "AWS.CloudTrail.IAMAnythingChanged"
DisplayName: "IAM Change"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Identity and Access Management
Reports:
Stratus Red Team:
- aws.persistence.iam-backdoor-role
- aws.persistence.iam-backdoor-user
- aws.persistence.iam-create-admin-user
- aws.persistence.iam-create-backdoor-role
- aws.persistence.iam-create-user-login-profile
- aws.privilege-escalation.iam-update-user-login-profile
Severity: Info
DedupPeriodMinutes: 720 # 12 hours
Description: >
A change occurred in the IAM configuration. This could be a resource being created, deleted, or modified. This is a high level view of changes, helfpul to indicate how dynamic a certain IAM environment is.
Runbook: >
Ensure this was an approved IAM configuration change.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/cloudtrail-integration.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceisiam.amazonaws.comany of:
eventNamestarts withAddeventNamestarts withAttacheventNamestarts withChangeeventNamestarts withCreateeventNamestarts withDeactivateeventNamestarts withDeleteeventNamestarts withDetacheventNamestarts withEnableeventNamestarts withPuteventNamestarts withRemoveeventNamestarts withSeteventNamestarts withUpdateeventNamestarts withUpload
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventSource | ne | iam.amazonaws.com | excludes:eventSource field:"eventSource" value:"iam.amazonaws.com" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | starts_with |
| field:"aws::eventName" kind:starts_with |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Ensure this was an approved IAM configuration change.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "1111",
"eventName": "AttachRolePolicy",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1111",
"requestParameters": {
"policyArn": "arn:aws:iam::aws:policy/example-policy",
"roleName": "LambdaFunctionRole-1111"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "cloudformation.amazonaws.com",
"userIdentity": {
"accesKeyId": "1111",
"accessKeyId": "1111",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/example-role/example-user",
"invokedBy": "cloudformation.amazonaws.com",
"principalId": "1111:example-user",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/example-role",
"principalId": "1111",
"type": "Role",
"userName": "example-user"
}
},
"type": "AssumedRole"
}
}
IAM Entity Created Without CloudFormation
#An IAM Entity (Group, Policy, Role, or User) was created manually. IAM entities should be created in code to ensure that permissions are tracked and managed correctly.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- ASL AWS Create Policy Version to allow all resources (Splunk)
- AWS Lateral Movement from Kubernetes SA via AssumeRoleWithWebIdentity (Elastic)
- AWSCloudTrail - CloudFormation policy created then used for privilege escalation (Kusto)
- AWSCloudTrail - Created CRUD S3 policy and then privilege escalation (Kusto)
- AWSCloudTrail - Creation of CRUD DynamoDB policy and then privilege escalation (Kusto)
- AWSCloudTrail - Creation of CRUD KMS policy and then privilege escalation (Kusto)
- AWSCloudTrail - Creation of CRUD Lambda policy and then privilege escalation (Kusto)
- AWSCloudTrail - Creation of DataPipeline policy and then privilege escalation (Kusto)
Detection logic
import re
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# The role dedicated for IAM administration
IAM_ADMIN_ROLES = {
"arn:aws:iam::123456789012:role/IdentityCFNServiceRole",
}
# The role patterns dedicated for IAM Service Roles
IAM_ADMIN_ROLE_PATTERNS = {"arn:aws:iam::[0-9]+:role/IdentityCFNServiceRole"}
# API calls that are indicative of IAM entity creation
IAM_ENTITY_CREATION_EVENTS = {
"BatchCreateUser",
"CreateGroup",
"CreateInstanceProfile",
"CreatePolicy",
"CreatePolicyVersion",
"CreateRole",
"CreateServiceLinkedRole",
"CreateUser",
}
def rule(event):
# Check if this event is in scope
if (
not aws_cloudtrail_success(event)
or event.get("eventName") not in IAM_ENTITY_CREATION_EVENTS
):
return False
# All IAM changes MUST go through CloudFormation
if event.deep_get("userIdentity", "invokedBy") != "cloudformation.amazonaws.com":
return True
# Only approved IAM Roles can make IAM Changes
for admin_role_pattern in IAM_ADMIN_ROLE_PATTERNS:
# Check if the arn matches any role patterns, return False if there is a match
if (
len(
re.findall(
admin_role_pattern,
event.deep_get("userIdentity", "sessionContext", "sessionIssuer", "arn"),
)
)
> 0
):
return False
return (
event.deep_get("userIdentity", "sessionContext", "sessionIssuer", "arn")
not in IAM_ADMIN_ROLES
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_iam_entity_created_without_cloudformation.py
RuleID: "AWS.CloudTrail.IAMEntityCreatedWithoutCloudFormation"
DisplayName: "IAM Entity Created Without CloudFormation"
Enabled: false
LogTypes:
- AWS.CloudTrail
Reports:
MITRE ATT&CK:
- TA0003:T1136
Tags:
- AWS
- Configuration Required
- Identity and Access Management
- Persistence:Create Account
Severity: Medium
Description: >
An IAM Entity (Group, Policy, Role, or User) was created manually. IAM entities should be created in code to ensure that permissions are tracked and managed correctly.
Runbook: >
Verify whether IAM entity needs to exist. If so, re-create it in an appropriate CloudFormation, Terraform, or other template. Delete the original manually created entity.
Reference: https://blog.awsfundamentals.com/aws-iam-roles-with-aws-cloudformation
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofBatchCreateUser,CreateGroup,CreateInstanceProfile,CreatePolicy,CreatePolicyVersionany of:
userIdentity.invokedByis notcloudformation.amazonaws.comuserIdentity.sessionContext.sessionIssuer.arnis not one ofarn:aws:iam::123456789012:role/IdentityCFNServiceRole
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
eventName | in | BatchCreateUser, CreateGroup, CreateInstanceProfile, CreatePolicy, CreatePolicyVersion, CreateRole, CreateServiceLinkedRole, CreateUser | excludes:eventName |
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
userIdentity.invokedBy | ne |
| field:"userIdentity.invokedBy" kind:ne value:"cloudformation.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Verify whether IAM entity needs to exist. If so, re-create it in an appropriate CloudFormation, Terraform, or other template. Delete the original manually created entity.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "1",
"eventName": "CreateUser",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"path": "/",
"userName": "user"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/IdentityCFNServiceRole",
"principalId": "1111",
"type": "Role",
"userName": "tester"
}
},
"type": "AssumedRole"
}
}
IAM Inline Policy Network Admin
#This policy validates that IAM entities (Groups, Roles, and Users) do not have inline policies attached that grant network admin privileges. Inline policies are more difficult to track and audit than managed policies, and can lead to persistent unexpected access.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Detection logic
import json
from policyuniverse.action_categories import categories_for_actions
from policyuniverse.expander_minimizer import expand_policy
from policyuniverse.policy import Policy
# White listed policies (e.g. the approved network admin policy) can be specified here, or as an
# exception to this policy.
ADMIN_ACTIONS = {
"Tagging",
"Write",
}
NETWORK_RESOURCES = {
"dhcpoptions",
"internetgateway",
"networkacl",
"networkinterface",
"routetable",
"securitygroup",
"subnet",
"transitgateway",
"vpc",
"vpn",
}
def is_ec2_admin_policy(iam_policy):
#
# These first two checks can technically be skipped and this policy will still return correct
# results, but they prevent the more computationally expensive check the majority of the time.
#
action_summary = iam_policy.action_summary()
# Check if the policy applies to EC2 resources
if "ec2" not in action_summary:
return False
# Check if the policy grants administrative privileges
if not ADMIN_ACTIONS.intersection(action_summary["ec2"]):
return False
# Get the EC2 actions pertaining specifically to network resources
network_actions = set()
for statement in iam_policy.statements:
# Only check statements granting access
if statement.effect != "Allow":
continue
# Only check actions that are granted on network resources
for action in statement.actions:
if any(resource in action for resource in NETWORK_RESOURCES):
network_actions.add(action)
# For all actions that have been granted on network resources, ensure none grant admin access
network_actions_summary = categories_for_actions(network_actions)
return any(action in ADMIN_ACTIONS for action in network_actions_summary["ec2"])
def policy(resource):
# This policy only applies to resources with an inline policy document
if resource["InlinePolicies"] is None:
return True
for inline_policy in resource["InlinePolicies"].values():
iam_policy = Policy(expand_policy(json.loads(inline_policy)))
if is_ec2_admin_policy(iam_policy):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_iam_inline_policy_does_not_grant_network_admin_access.py
PolicyID: "AWS.IAM.Entity.InlinePolicyDoesNotGrantNetworkAdminAccess"
DisplayName: "IAM Inline Policy Network Admin"
Enabled: true
ResourceTypes:
- AWS.IAM.User
- AWS.IAM.Role
- AWS.IAM.Group
Tags:
- AWS
- PCI
- Persistence:Valid Accounts
Reports:
PCI:
- 1.1.5
- 2.2.4
- 7.1.2
MITRE ATT&CK:
- TA0003:T1078
Severity: Medium
Description: >
This policy validates that IAM entities (Groups, Roles, and Users) do not have inline policies attached that grant network admin privileges. Inline policies are more difficult to track and audit than managed policies, and can lead to persistent unexpected access.
Runbook: >
Remove the inline policy, and if the IAM entity needs the provided permissions create an IAM managed policy with those permissions and apply it to the entity.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html
Stages and Predicates
Flags AWS.IAM.User, AWS.IAM.Role, AWS.IAM.Group resources when the condition below holds.
Condition
InlinePoliciesis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
InlinePolicies | is_null | excludes:InlinePolicies |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
InlinePolicies | is_not_null | field:"InlinePolicies" kind:is_not_null |
Response runbook
Remove the inline policy, and if the IAM entity needs the provided permissions create an IAM managed policy with those permissions and apply it to the entity.
IAM Policy Modified
#An IAM Policy was changed.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- ASL AWS Create Policy Version to allow all resources (Splunk)
- ASL AWS IAM Delete Policy (Splunk)
- AWS IAM Access Key Compromise Detection (Panther)
- AWS IAM CompromisedKeyQuarantine Policy Attached to User (Elastic)
- AWS IAM Delete Policy (Splunk)
- AWS Lateral Movement from Kubernetes SA via AssumeRoleWithWebIdentity (Elastic)
- AWSCloudTrail - CloudFormation policy created then used for privilege escalation (Kusto)
- AWSCloudTrail - Created CRUD S3 policy and then privilege escalation (Kusto)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of IAM Policy changes
POLICY_CHANGE_EVENTS = {
"DeleteGroupPolicy",
"DeleteRolePolicy",
"DeleteUserPolicy",
# Put<Entity>Policy is for inline policies.
# these can be moved into their own rule if inline policies are of a greater concern.
"PutGroupPolicy",
"PutRolePolicy",
"PutUserPolicy",
"CreatePolicy",
"DeletePolicy",
"CreatePolicyVersion",
"DeletePolicyVersion",
"AttachRolePolicy",
"DetachRolePolicy",
"AttachUserPolicy",
"DetachUserPolicy",
"AttachGroupPolicy",
"DetachGroupPolicy",
}
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") in POLICY_CHANGE_EVENTS
def dedup(event):
return event.get("recipientAccountId")
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_iam_policy_modified.py
RuleID: "AWS.IAM.PolicyModified"
DisplayName: "IAM Policy Modified"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Identity & Access Management
- Privilege Escalation:Abuse Elevation Control Mechanism
Reports:
CIS:
- 3.4
MITRE ATT&CK:
- TA0004:T1548
Stratus Red Team:
- aws.persistence.iam-create-admin-user
- aws.persistence.iam-create-backdoor-role
Severity: Info
DedupPeriodMinutes: 720 # 12 hours
Description: >
An IAM Policy was changed.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-iam-policy-modified
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofDeleteGroupPolicy,DeleteRolePolicy,DeleteUserPolicy,PutGroupPolicy,PutRolePolicy
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-iam-policy-modified
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "1",
"eventName": "DeleteGroupPolicy",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"groupName": "group",
"policyName": "policy"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "tester"
}
},
"type": "AssumedRole"
}
}
IAM Role Added to RDS Instance or Cluster
#Detects when IAM roles are added to RDS instances or clusters. While legitimate for features like S3 import/export, attackers may add overly permissive roles to maintain access or escalate privileges for data exfiltration.
MITRE ATT&CK coverage
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_rds_context
def rule(event):
if event.get("eventSource") != "rds.amazonaws.com":
return False
role_events = ["AddRoleToDBInstance", "AddRoleToDBCluster"]
if event.get("eventName") not in role_events:
return False
return event.deep_get("errorCode") is None
def title(event):
event_name = event.get("eventName", "Unknown")
db_identifier = event.deep_get("requestParameters", "dBInstanceIdentifier") or event.deep_get(
"requestParameters", "dBClusterIdentifier", default="<UNKNOWN>"
)
role_arn = event.deep_get("requestParameters", "roleArn", default="<UNKNOWN_ROLE>")
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "principalId", default="<UNKNOWN_USER>"
)
resource_type = "Instance" if event_name == "AddRoleToDBInstance" else "Cluster"
return f"IAM Role Added to RDS {resource_type}: [{db_identifier}] role [{role_arn}] by [{user}]"
def dedup(event):
db_identifier = event.deep_get("requestParameters", "dBInstanceIdentifier") or event.deep_get(
"requestParameters", "dBClusterIdentifier", default="unknown"
)
account_id = event.deep_get("recipientAccountId", default="unknown")
region = event.get("awsRegion", "unknown")
return f"{account_id}:{region}:{db_identifier}"
def alert_context(event):
context = aws_rds_context(event)
context["role_arn"] = event.deep_get("requestParameters", "roleArn", default="N/A")
context["feature_name"] = event.deep_get("requestParameters", "featureName", default="N/A")
return context
Rule specification
AnalysisType: rule
Filename: aws_rds_iam_role_added.py
RuleID: "AWS.RDS.IAMRoleAdded"
DisplayName: "IAM Role Added to RDS Instance or Cluster"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Persistence
- Privilege Escalation
- Additional Cloud Credentials
- RDS
Severity: Medium
Description: >
Detects when IAM roles are added to RDS instances or clusters. While legitimate for features
like S3 import/export, attackers may add overly permissive roles to maintain access or
escalate privileges for data exfiltration.
Runbook: |
1. Find all IAM role additions by the user ARN in the past 24 hours
2. Check if this user has added IAM roles to databases in the past 90 days to determine if this is normal behavior
3. Look for data export or modification operations from this database in the 48 hours after the role was added
Reference: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_PostgreSQL.S3Import.html
Reports:
MITRE ATT&CK:
- TA0003:T1098.001 # Additional Cloud Credentials
- TA0004:T1078.004 # Cloud Accounts
DedupPeriodMinutes: 60
SummaryAttributes:
- eventName
- userIdentity:principalId
- requestParameters:dBInstanceIdentifier
- requestParameters:dBClusterIdentifier
- requestParameters:roleArn
- p_any_aws_account_ids
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceisrds.amazonaws.comeventNameis one ofAddRoleToDBInstance,AddRoleToDBClustererrorCodeis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"rds.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
dBInstanceIdentifier | requestParameters.dBInstanceIdentifier |
roleArn | requestParameters.roleArn |
userName | userIdentity.userName |
Response runbook
1. Find all IAM role additions by the user ARN in the past 24 hours
2. Check if this user has added IAM roles to databases in the past 90 days to determine if this is normal behavior
3. Look for data export or modification operations from this database in the 48 hours after the role was added
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "f1e2d3c4-b5a6-7890-1234-567890abcdef",
"eventName": "AddRoleToDBInstance",
"eventSource": "rds.amazonaws.com",
"eventTime": "2024-01-17T13:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"requestParameters": {
"dBInstanceIdentifier": "production-postgres",
"featureName": "s3Import",
"roleArn": "arn:aws:iam::123456789012:role/RDS-S3-Import-Role"
},
"responseElements": {
"associatedRoles": [
{
"featureName": "s3Import",
"roleArn": "arn:aws:iam::123456789012:role/RDS-S3-Import-Role",
"status": "PENDING"
}
],
"dBInstanceIdentifier": "production-postgres"
},
"sourceIPAddress": "10.0.1.100",
"userAgent": "aws-cli/2.13.0",
"userIdentity": {
"accessKeyId": "ASIAIOSFODNN7EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/DBARole/user",
"principalId": "AIDAI23HXS3EXAMPLE:user",
"type": "AssumedRole"
}
}
IAM Role Created
#An IAM role was created.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") == "CreateRole"
def alert_context(event):
context = aws_rule_context(event)
context["request_rolename"] = event.deep_get(
"requestParameters", "roleName", default="ROLENAME_NOT_FOUND"
)
return context
Rule specification
AnalysisType: rule
Filename: aws_iam_create_role.py
RuleID: "AWS.IAM.CreateRole"
DisplayName: "IAM Role Created"
Enabled: true
LogTypes:
- AWS.CloudTrail
CreateAlert: false
Reports:
CIS:
- 1.1
MITRE ATT&CK:
- TA0007:T1078
Severity: Info
Description: >
An IAM role was created.
Runbook: Check if the action was expected.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisCreateRole
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"CreateRole" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Check if the action was expected.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "CreateRole",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"roleName": "new-role"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "Tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
IAM Role Policy Updated to Allow Internet Access
#An IAM role policy was updated to allow internet access, which could indicate a backdoor.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Detection logic
import json
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
from policyuniverse.policy import Policy
def rule(event):
if not aws_cloudtrail_success(event) or event.get("eventName") != "UpdateAssumeRolePolicy":
return False
policy = event.deep_get("requestParameters", "policyDocument", default="{}")
return Policy(json.loads(policy)).is_internet_accessible()
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_iam_backdoor_role.py
RuleID: "AWS.IAM.BackdoorRole"
DisplayName: "IAM Role Policy Updated to Allow Internet Access"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- IAM
Reports:
CIS:
- 1.1
MITRE ATT&CK:
- TA0007:T1078
Severity: Medium
Description: >
An IAM role policy was updated to allow internet access, which could indicate a backdoor.
Runbook: Check if the action was authorized and if the policy was updated by a trusted user. If not, revert the policy and investigate the user
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisUpdateAssumeRolePolicy
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | ne | UpdateAssumeRolePolicy | excludes:eventName field:"eventName" value:"UpdateAssumeRolePolicy" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | is_null | field:"aws::errorCode" kind:is_null | |
errorMessage | is_null | field:"aws::errorMessage" kind:is_null | |
eventName | eq |
| field:"aws::eventName" kind:eq value:"UpdateAssumeRolePolicy" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Check if the action was authorized and if the policy was updated by a trusted user. If not, revert the policy and investigate the user
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "UpdateAssumeRolePolicy",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"policyDocument": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"sts:AssumeRole\",\"Condition\":{\"StringEquals\":{\"sts:ExternalId\":\"12345\"}}}]}"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "Tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
IAM User Created
#An IAM user was created, which could indicate a new user creation or policy update.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") == "CreateUser"
def alert_context(event):
context = aws_rule_context(event)
context["request_username"] = event.deep_get(
"requestParameters", "userName", default="USERNAME_NOT_FOUND"
)
return context
Rule specification
AnalysisType: rule
Filename: aws_iam_create_user.py
RuleID: "AWS.IAM.CreateUser"
DisplayName: "IAM User Created"
Enabled: true
LogTypes:
- AWS.CloudTrail
Reports:
CIS:
- 1.1
MITRE ATT&CK:
- TA0007:T1078
Severity: Info
CreateAlert: false
Description: >
An IAM user was created, which could indicate a new user creation or policy update.
Runbook: Check if the user was created by an authorized user. If not, investigate the user creation.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisCreateUser
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"CreateUser" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Check if the user was created by an authorized user. If not, investigate the user creation.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "CreateUser",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"userName": "new-user"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "Tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
IAM User Policy Attached with Administrator Access
#An IAM user policy was attached with Administrator Access, which could indicate a potential security risk.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
if not aws_cloudtrail_success(event) or event.get("eventName") != "AttachUserPolicy":
return False
policy = event.deep_get("requestParameters", "policyArn", default="POLICY_NOT_FOUND")
return policy.endswith("AdministratorAccess")
def alert_context(event):
context = aws_rule_context(event)
context["request_username"] = event.deep_get(
"requestParameters", "userName", default="USERNAME_NOT_FOUND"
)
return context
Rule specification
AnalysisType: rule
Filename: aws_iam_attach_admin_user_policy.py
RuleID: "AWS.IAM.AttachAdminUserPolicy"
DisplayName: "IAM User Policy Attached with Administrator Access"
Enabled: true
CreateAlert: false
LogTypes:
- AWS.CloudTrail
Reports:
CIS:
- 1.1
MITRE ATT&CK:
- TA0007:T1078
Severity: Info
Description: >
An IAM user policy was attached with Administrator Access, which could indicate a potential security risk.
Runbook: Check if the user policy was attached by an authorized user. If not, investigate the user policy attachment.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameisAttachUserPolicyrequestParameters.policyArnends withAdministratorAccess
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | ne | AttachUserPolicy | excludes:eventName field:"eventName" value:"AttachUserPolicy" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
requestParameters.policyArn | ends_with |
| field:"requestParameters.policyArn" kind:ends_with value:"AdministratorAccess" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Check if the user policy was attached by an authorized user. If not, investigate the user policy attachment.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "AttachUserPolicy",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"policyArn": "arn:aws:iam::aws:policy/AdministratorAccess",
"userName": "new-user"
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "console.amazonaws.com",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "Tester"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
KMS CMK Disabled or Deleted
#A KMS Customer Managed Key was disabled or scheduled for deletion. This could potentially lead to permanent loss of encrypted data.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
# API calls that are indicative of KMS CMK Deletion
KMS_LOSS_EVENTS = {"DisableKey", "ScheduleKeyDeletion"}
KMS_KEY_TYPE = "AWS::KMS::Key"
def rule(event):
return aws_cloudtrail_success(event) and event.get("eventName") in KMS_LOSS_EVENTS
def dedup(event):
for resource in event.get("resources") or []:
if resource.get("type", "") == KMS_KEY_TYPE:
return resource.get("ARN")
return event.get("eventName")
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_kms_cmk_loss.py
RuleID: "AWS.KMS.CustomerManagedKeyLoss"
DisplayName: "KMS CMK Disabled or Deleted"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Identity & Access Management
- Impact:Data Destruction
Reports:
CIS:
- 3.7
MITRE ATT&CK:
- TA0040:T1485
Severity: Info
Description: >
A KMS Customer Managed Key was disabled or scheduled for deletion. This could potentially lead to permanent loss of encrypted data.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-kms-cmk-loss
Reference: https://docs.aws.amazon.com/kms/latest/developerguide/deleting-keys.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventNameis one ofDisableKey,ScheduleKeyDeletion
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-kms-cmk-loss
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "DisableKey",
"eventSource": "kms.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"keyId": "1"
},
"resources": [
{
"ARN": "arn:aws:kms:us-west-2:123456789012:key/1",
"accountId": "123456789012",
"type": "AWS::KMS::Key"
}
],
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/tester",
"principalId": "1111:tester",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/tester",
"principalId": "1111",
"type": "Role",
"userName": "tester"
}
},
"type": "AssumedRole"
}
}
Lambda Code Updated by User
#Detects when Lambda function code is updated by an IAM user, federated user, or AWS SSO user. This may indicate compromised credentials, a developer bypassing CI/CD guardrails, or insider threat activity.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Discovery API Calls via CLI from a Single Resource (Elastic)
- AWS Lambda Event Source Mapping Creation (Elastic)
- AWS Lambda Function Created or Updated (Elastic)
- AWS Lambda Function High-Frequency Invocation by a Single Principal (Elastic)
- AWS Lambda Function Invoked by an Unusual Principal (Elastic)
- AWS Lambda Function Invoked Cross-Account (Elastic)
- AWS Lambda Function Invoked from an Unusual Source ASN (Elastic)
- AWS Lambda Function Policy Updated to Allow Cross-Account Invocation (Elastic)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
if not (
aws_cloudtrail_success(event)
and event.get("eventSource") == "lambda.amazonaws.com"
and event.get("eventName").startswith("UpdateFunctionCode")
):
return False
identity_type = event.deep_get("userIdentity", "type", default="")
if identity_type in ("IAMUser", "FederatedUser"):
return True
if identity_type == "AssumedRole":
role_name = event.deep_get(
"userIdentity", "sessionContext", "sessionIssuer", "userName", default=""
)
return role_name.startswith("AWSReservedSSO_")
return False
def title(event):
lambda_name = event.deep_get(
"responseElements", "functionName", default="LAMBDA_NAME_NOT_FOUND"
)
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"updated Lambda function code for [{lambda_name}]"
)
def alert_context(event):
context = aws_rule_context(event)
context["identity_type"] = event.deep_get("userIdentity", "type")
context["user_arn"] = event.deep_get("userIdentity", "arn")
return context
Rule specification
AnalysisType: rule
Filename: aws_overwrite_lambda_code.py
RuleID: "AWS.Lambda.UpdateFunctionCode"
DisplayName: "Lambda Code Updated by User"
Enabled: true
CreateAlert: true
LogTypes:
- AWS.CloudTrail
Reports:
MITRE ATT&CK:
- TA0007:T1078
Severity: High
Status: Experimental
Description: >
Detects when Lambda function code is updated by an IAM user, federated user, or AWS SSO user.
This may indicate compromised credentials, a developer bypassing CI/CD guardrails,
or insider threat activity.
Runbook: |
Verify the user identity and whether this is authorized.
Review the code changes made to the Lambda function.
If unauthorized:
- Disable the user's access key or revoke the SSO session
- Revert Lambda function code to previous version
- Investigate credential compromise
Reference: https://stratus-red-team.cloud/attack-techniques/AWS/aws.persistence.lambda-overwrite-code/
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceislambda.amazonaws.comeventNamestarts withUpdateFunctionCodeany of:
userIdentity.typeis one ofIAMUser,FederatedUserall of:
userIdentity.typeisAssumedRoleuserIdentity.sessionContext.sessionIssuer.userNamestarts withAWSReservedSSO_
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | starts_with |
| field:"aws::eventName" kind:starts_with value:"UpdateFunctionCode" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"lambda.amazonaws.com" |
userIdentity.sessionContext.sessionIssuer.userName | starts_with |
| field:"userIdentity.sessionContext.sessionIssuer.userName" kind:starts_with value:"AWSReservedSSO_" |
userIdentity.type | eq |
| field:"aws::userIdentity.type" kind:eq value:"AssumedRole" |
userIdentity.type | in |
| field:"aws::userIdentity.type" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
functionName | responseElements.functionName |
Response runbook
Verify the user identity and whether this is authorized.
Review the code changes made to the Lambda function.
If unauthorized:
- Disable the user's access key or revoke the SSO session
- Revert Lambda function code to previous version
- Investigate credential compromise
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "UpdateFunctionCode20150331v2",
"eventSource": "lambda.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"p_log_type": "AWS.CloudTrail",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"functionName": "production-api"
},
"responseElements": {
"functionName": "production-api"
},
"sourceIPAddress": "203.0.113.50",
"userAgent": "aws-cli/2.13.5 Python/3.11.4",
"userIdentity": {
"accessKeyId": "AKIAI123456789EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/john.developer",
"principalId": "AIDAI123456789EXAMPLE",
"type": "IAMUser",
"userName": "john.developer"
}
}
Lambda Configuration Updated with Layers by User
#Detects when Lambda function configuration is updated with layers by an IAM user, federated user, or AWS SSO user. Lambda layers can execute code before the main function, making them a stealthy persistence mechanism. This may indicate compromised credentials, a developer bypassing CI/CD guardrails, or insider threat activity.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
if not (
aws_cloudtrail_success(event)
and event.get("eventSource") == "lambda.amazonaws.com"
and event.get("eventName") == "UpdateFunctionConfiguration20150331v2"
and event.deep_get("responseElements", "layers")
):
return False
identity_type = event.deep_get("userIdentity", "type", default="")
if identity_type in ("IAMUser", "FederatedUser"):
return True
if identity_type == "AssumedRole":
role_name = event.deep_get(
"userIdentity", "sessionContext", "sessionIssuer", "userName", default=""
)
return role_name.startswith("AWSReservedSSO_")
return False
def title(event):
lambda_name = event.deep_get(
"responseElements", "functionName", default="LAMBDA_NAME_NOT_FOUND"
)
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"updated Lambda function configuration with layers for [{lambda_name}]"
)
def alert_context(event):
context = aws_rule_context(event)
context["identity_type"] = event.deep_get("userIdentity", "type")
context["user_arn"] = event.deep_get("userIdentity", "arn")
layers = event.deep_get("responseElements", "layers", default=[])
context["layer_arns"] = [layer.get("arn") for layer in layers]
return context
Rule specification
AnalysisType: rule
Filename: aws_add_malicious_lambda_extension.py
RuleID: "AWS.Lambda.UpdateFunctionConfiguration"
DisplayName: "Lambda Configuration Updated with Layers by User"
Enabled: true
LogTypes:
- AWS.CloudTrail
Reports:
MITRE ATT&CK:
- TA0007:T1078
Severity: High
CreateAlert: true
Description: >
Detects when Lambda function configuration is updated with layers by an IAM user,
federated user, or AWS SSO user. Lambda layers can execute code before the main function,
making them a stealthy persistence mechanism. This may indicate compromised credentials,
a developer bypassing CI/CD guardrails, or insider threat activity.
Runbook: |
Verify the user identity and layer ARNs are authorized.
Check if layers are from trusted sources (same account, known publishers).
If unauthorized:
- Disable the user's access key or revoke the SSO session
- Revert Lambda configuration to previous version
- Investigate credential compromise
Reference: https://stratus-red-team.cloud/attack-techniques/AWS/aws.persistence.lambda-layer-extension/
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceislambda.amazonaws.comeventNameisUpdateFunctionConfiguration20150331v2responseElements.layersis presentany of:
userIdentity.typeis one ofIAMUser,FederatedUserall of:
userIdentity.typeisAssumedRoleuserIdentity.sessionContext.sessionIssuer.userNamestarts withAWSReservedSSO_
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"UpdateFunctionConfiguration20150331v2" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"lambda.amazonaws.com" |
responseElements.layers | is_not_null | field:"responseElements.layers" kind:is_not_null | |
userIdentity.sessionContext.sessionIssuer.userName | starts_with |
| field:"userIdentity.sessionContext.sessionIssuer.userName" kind:starts_with value:"AWSReservedSSO_" |
userIdentity.type | eq |
| field:"aws::userIdentity.type" kind:eq value:"AssumedRole" |
userIdentity.type | in |
| field:"aws::userIdentity.type" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
functionName | responseElements.functionName |
Response runbook
Verify the user identity and layer ARNs are authorized.
Check if layers are from trusted sources (same account, known publishers).
If unauthorized:
- Disable the user's access key or revoke the SSO session
- Revert Lambda configuration to previous version
- Investigate credential compromise
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "UpdateFunctionConfiguration20150331v2",
"eventSource": "lambda.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"p_log_type": "AWS.CloudTrail",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"functionName": "production-api"
},
"responseElements": {
"functionName": "production-api",
"layers": [
{
"arn": "arn:aws:lambda:us-west-2:123456789012:layer:malicious-layer:1"
}
]
},
"sourceIPAddress": "203.0.113.50",
"userAgent": "aws-cli/2.13.5 Python/3.11.4",
"userIdentity": {
"accessKeyId": "AKIAI123456789EXAMPLE",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/john.developer",
"principalId": "AIDAI123456789EXAMPLE",
"type": "IAMUser",
"userName": "john.developer"
}
}
Lambda CRUD Actions
#Unauthorized lambda Create, Read, Update, or Delete event occurred.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Lambda Function Deletion (Elastic)
Detection logic
from fnmatch import fnmatch
from panther_aws_helpers import aws_rule_context
LAMBDA_CRUD_EVENTS = {
"AddPermission",
"CreateAlias",
"CreateEventSourceMapping",
"CreateFunction",
"DeleteAlias",
"DeleteEventSourceMapping",
"DeleteFunction",
"PublishVersion",
"RemovePermission",
"UpdateAlias",
"UpdateEventSourceMapping",
"UpdateFunctionCode",
"UpdateFunctionConfiguration",
}
ALLOWED_ROLES = [
"*DeployRole",
]
def rule(event):
if (
event.get("eventSource") == "lambda.amazonaws.com"
and event.get("eventName") in LAMBDA_CRUD_EVENTS
):
for role in ALLOWED_ROLES:
if fnmatch(event.deep_get("userIdentity", "arn", default="unknown-arn"), role):
return False
return True
return False
def title(event):
return (
f"[{event.deep_get('userIdentity','arn', default = 'unknown-arn')}] "
f"performed Lambda "
f"[{event.get('eventName')}] in "
f"[{event.get('recipientAccountId')} {event.get('awsRegion')}]."
)
def dedup(event):
return f"{event.deep_get('userIdentity','arn', default = 'unknown-arn')}"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_lambda_crud.py
RuleID: "AWS.LAMBDA.CRUD"
DisplayName: "Lambda CRUD Actions"
Enabled: false
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Security Control
- Configuration Required
Reports:
CIS:
- 3.12
MITRE ATT&CK:
- TA0005:T1525
Severity: High
Description: Unauthorized lambda Create, Read, Update, or Delete event occurred.
Runbook: https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html
Reference: https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html
SummaryAttributes:
- eventSource
- eventName
- recipientAccountId
- awsRegion
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceislambda.amazonaws.comeventNameis one ofAddPermission,CreateAlias,CreateEventSourceMapping,CreateFunction,DeleteAliasuserIdentity.arndoes not match the pattern*DeployRole
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
userIdentity.arn | ends_with | DeployRole | excludes:userIdentity.arn field:"userIdentity.arn" value:"DeployRole" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"lambda.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
arn | userIdentity.arn |
Response runbook
https://docs.aws.amazon.com/lambda/latest/dg/logging-using-cloudtrail.html
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "20b84ce5-730f-482e-b2b2-e8fcc87ceb22",
"eventName": "DeleteFunction",
"eventSource": "lambda.amazonaws.com",
"eventTime": "2015-03-18T19:04:42Z",
"eventType": "AwsApiCall",
"eventVersion": "1.03",
"recipientAccountId": "999999999999",
"requestID": "a2198ecc-cda1-11e4-aaa2-e356da31e4ff",
"requestParameters": {
"functionName": "basic-node-task"
},
"responseElements": null,
"sourceIPAddress": "127.0.0.1",
"userAgent": "Python-httplib2/0.8 (gzip)",
"userIdentity": {
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
"accountId": "999999999999",
"arn": "arn:aws:iam::999999999999:user/myUserName",
"principalId": "A1B2C3D4E5F6G7EXAMPLE",
"type": "IAMUser",
"userName": "myUserName"
}
}
Logins Without MFA
#A console login was made without multi-factor authentication.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Console Login (Panther)
- AWS Console Login Failed During MFA Challenge (Splunk)
- AWS ConsoleLogin Failed Authentication (Sigma)
- AWS CreateLoginProfile (Splunk)
- AWS Credential Access Failed Login (Splunk)
- AWS High Number Of Failed Authentications For User (Splunk)
- AWS High Number Of Failed Authentications From Ip (Splunk)
- AWS Multiple Failed MFA Requests For User (Splunk)
Detection logic
import logging
from panther_aws_helpers import aws_rule_context
from panther_detection_helpers.caching import check_account_age
# Set to True for environments that permit direct role assumption via external IDP
ROLES_VIA_EXTERNAL_IDP = False
# pylint: disable=R0911,R0912,R1260
def rule(event):
if event.get("eventName") != "ConsoleLogin":
return False
# Extract some nested JSON structure
additional_event_data = event.get("additionalEventData", {})
response_elements = event.get("responseElements", {})
user_identity_type = event.deep_get("userIdentity", "type", default="")
# When there is an external IdP setup and users directly assume roles
# the additionalData.MFAUsed attribute will be set to "no"
# AND the userIdentity.sessionContext.mfaAuthenticated attribute will be "false"
#
# This will create a lack of visibility into the condition where
# users are allowed to directly AssumeRole outside of the IdP and without MFA
#
# To date we have not identified data inside the log events that clearly
# delinates AssumeRole backed by an external IdP vs not backed by external IdP
if ROLES_VIA_EXTERNAL_IDP and user_identity_type == "AssumedRole":
return False
# If using AWS SSOv2 or other SAML provider return False
if (
"AWSReservedSSO" in event.deep_get("userIdentity", "arn", default=" ")
or additional_event_data.get("SamlProviderArn") is not None
):
return False
# If Account is less than 3 days old do not alert
# This functionality is not enabled by default, in order to start logging new user creations
# Enable indicator_creation_rules/new_account_logging to start logging new users
new_user_string = (
event.deep_get("userIdentity", "userName", default="<MISSING_USER_NAME>")
+ "-"
+ event.udm("actor_user")
)
is_new_user = check_account_age(new_user_string)
if isinstance(is_new_user, str):
logging.debug("check_account_age is a mocked string for unit testing")
if is_new_user == "False":
is_new_user = False
if is_new_user == "True":
is_new_user = True
if is_new_user:
return False
new_account_string = "new_account - " + str(event.get("recipientAccountId"))
is_new_account = check_account_age(new_account_string)
if isinstance(is_new_account, str):
logging.debug("check_account_age is a mocked string for unit testing")
if is_new_account == "False":
is_new_account = False
if is_new_account == "True":
is_new_account = True
if is_new_account:
return False
if response_elements.get("ConsoleLogin") == "Success":
# This logic is inverted because at times the second condition is None.
# It is not recommended to remove this 'double negative"
if (
additional_event_data.get("MFAUsed") != "Yes"
and event.deep_get("userIdentity", "sessionContext", "attributes", "mfaAuthenticated")
!= "true"
):
return True
return False
def title(event):
if event.deep_get("userIdentity", "type") == "Root":
user_string = "the root user"
else:
user = event.deep_get("userIdentity", "userName") or event.deep_get(
"userIdentity", "sessionContext", "sessionIssuer", "userName"
)
type_ = event.deep_get(
"userIdentity", "sessionContext", "sessionIssuer", "type", default="user"
).lower()
user_string = f"{type_} {user}"
account_id = event.get("recipientAccountId")
return f"AWS login detected without MFA for [{user_string}] in [{account_id}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_console_login_without_mfa.py
RuleID: "AWS.Console.LoginWithoutMFA"
DisplayName: "Logins Without MFA"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Identity & Access Management
- Authentication
- Initial Access:Valid Accounts
Reports:
CIS:
- 3.2
MITRE ATT&CK:
- TA0001:T1078
Stratus Red Team:
- aws.initial-access.console-login-without-mfa
Severity: High
Description: A console login was made without multi-factor authentication.
Runbook: https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-console-login-without-mfa
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisConsoleLoginuserIdentity.typeis notAssumedRoleuserIdentity.arndoes not containAWSReservedSSOadditionalEventData.SamlProviderArnis emptyresponseElements.ConsoleLoginisSuccessadditionalEventData.MFAUsedis notYesuserIdentity.sessionContext.attributes.mfaAuthenticatedis nottrue
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
additionalEventData.SamlProviderArn | is_not_null | excludes:additionalEventData.SamlProviderArn | |
userIdentity.arn | contains | AWSReservedSSO | excludes:userIdentity.arn field:"userIdentity.arn" value:"AWSReservedSSO" |
userIdentity.type | eq | AssumedRole | excludes:userIdentity.type field:"userIdentity.type" value:"AssumedRole" |
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
https://docs.runpanther.io/alert-runbooks/built-in-rules/aws-console-login-without-mfa
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"LoginTo": "https://console.aws.amazon.com/console/",
"MFAUsed": "No",
"MobileVersion": "No"
},
"awsRegion": "us-east-1",
"eventID": "1",
"eventName": "ConsoleLogin",
"eventSource": "signin.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsConsoleSignIn",
"eventVersion": "1.05",
"p_log_type": "AWS.CloudTrail",
"recipientAccountId": "123456789012",
"requestParameters": null,
"responseElements": {
"ConsoleLogin": "Success"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla",
"userIdentity": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/tester",
"principalId": "1111",
"type": "IAMUser",
"userName": "tester"
}
}
Logins Without SAML
#An AWS console login was made without SAML/SSO.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Console Login (Panther)
- AWS Console Login Failed During MFA Challenge (Splunk)
- AWS ConsoleLogin Failed Authentication (Sigma)
- AWS CreateLoginProfile (Splunk)
- AWS Credential Access Failed Login (Splunk)
- AWS High Number Of Failed Authentications For User (Splunk)
- AWS High Number Of Failed Authentications From Ip (Splunk)
- AWS Multiple Failed MFA Requests For User (Splunk)
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
additional_event_data = event.get("additionalEventData", {})
return (
event.get("eventName") == "ConsoleLogin"
and event.deep_get("userIdentity", "type") != "AssumedRole"
and not additional_event_data.get("SamlProviderArn")
)
def title(event):
return f"AWS logins without SAML in account " f"[{event.get('recipientAccountId')}]"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_console_login_without_saml.py
RuleID: "AWS.Console.LoginWithoutSAML"
DisplayName: "Logins Without SAML"
DedupPeriodMinutes: 60
Enabled: false
LogTypes:
- AWS.CloudTrail
Reports:
MITRE ATT&CK:
- TA0001:T1078
Tags:
- AWS
- Configuration Required
- Identity & Access Management
- Authentication
- Initial Access:Valid Accounts
Severity: High
Description: An AWS console login was made without SAML/SSO.
Runbook: Modify the AWS account configuration.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-saml.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisConsoleLoginuserIdentity.typeis notAssumedRoleadditionalEventData.SamlProviderArnis empty
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Modify the AWS account configuration.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"LoginTo": "https://console.aws.amazon.com/console/",
"MFAUsed": "Yes",
"MobileVersion": "No"
},
"awsRegion": "us-east-1",
"eventID": "1",
"eventName": "ConsoleLogin",
"eventSource": "signin.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsConsoleSignIn",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestParameters": null,
"responseElements": {
"ConsoleLogin": "Success"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla",
"userIdentity": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/tester",
"principalId": "1111",
"type": "IAMUser",
"userName": "tester"
}
}
New IAM Credentials Updated
#A console password, access key, or user has been created.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- ASL AWS Create Access Key (Splunk)
- AWS CreateAccessKey (Splunk)
- AWS CreateLoginProfile (Splunk)
- AWS Lateral Movement from Kubernetes SA via AssumeRoleWithWebIdentity (Elastic)
- AWSCloudTrail - Creation of Access Key for IAM User (Kusto)
- IAM Entity Created Without CloudFormation (Panther)
- IAM User Created (Panther)
- Root Account Access Key Created (Panther)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
UPDATE_EVENTS = {"ChangePassword", "CreateAccessKey", "CreateLoginProfile", "CreateUser"}
def rule(event):
return event.get("eventName") in UPDATE_EVENTS and aws_cloudtrail_success(event)
def dedup(event):
return event.deep_get("userIdentity", "userName", default="<UNKNOWN_USER>")
def title(event):
return (
f"{event.deep_get('userIdentity', 'type')} [{event.deep_get('userIdentity', 'arn')}]"
f" has updated their IAM credentials"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_update_credentials.py
RuleID: "AWS.IAM.CredentialsUpdated"
DisplayName: "New IAM Credentials Updated"
Enabled: true
LogTypes:
- AWS.CloudTrail
Reports:
MITRE ATT&CK:
- TA0003:T1098
Stratus Red Team:
- aws.persistence.iam-backdoor-user
- aws.persistence.iam-create-admin-user
- aws.persistence.iam-create-user-login-profile
Tags:
- AWS
- Identity & Access Management
- Persistence:Account Manipulation
Severity: Info
Description: A console password, access key, or user has been created.
Runbook: This rule is purely informational, there is no action needed.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/list_identityandaccessmanagement.html
SummaryAttributes:
- eventName
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameis one ofChangePassword,CreateAccessKey,CreateLoginProfile,CreateUsererrorCodeis emptyerrorMessageis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
type | userIdentity.type |
arn | userIdentity.arn |
Response runbook
This rule is purely informational, there is no action needed.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "a431f05e-67e1-11ea-bc55-0242ac130003",
"eventName": "ChangePassword",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-12-31T01:50:46Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "a431f05e-67e1-11ea-bc55-0242ac130003",
"requestParameters": null,
"responseElements": null,
"sourceIPAddress": "64.25.27.224",
"userAgent": "signin.amazonaws.com",
"userIdentity": {
"accessKeyId": "AAAAIIIIIIU74NPJW5K76",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:user/test_user",
"invokedBy": "signin.amazonaws.com",
"principalId": "AAAAIIIIIIU74NPJW5K76",
"sessionContext": {
"attributes": {
"creationDate": "2019-12-31T01:50:17Z",
"mfaAuthenticated": "true"
}
},
"type": "IAMUser",
"userName": "test_user"
}
}
Root Account Access Key Created
#Detects creation of programmatic access keys for the AWS root account, which violates critical security best practices. Root account credentials provide unrestricted access to all AWS resources and cannot be scoped with granular permissions. If compromised, these keys grant attackers complete control over the AWS environment including billing and account closure capabilities.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
# Only check access key creation events
if event.get("eventName") != "CreateAccessKey":
return False
# Only root can create root access keys
if event.deep_get("userIdentity", "type") != "Root":
return False
# Only alert if the root user is creating an access key for itself
return event.get("requestParameters") is None
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_root_access_key_created.py
RuleID: "AWS.CloudTrail.RootAccessKeyCreated"
DisplayName: "Root Account Access Key Created"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Identity and Access Management
- Persistence:Account Manipulation
Reports:
MITRE ATT&CK:
- TA0003:T1098
Severity: Critical
Description: >
Detects creation of programmatic access keys for the AWS root account, which violates critical security best practices. Root account credentials provide unrestricted access to all AWS resources and cannot be scoped with granular permissions. If compromised, these keys grant attackers complete control over the AWS environment including billing and account closure capabilities.
Runbook: |
1. Query CloudTrail for all API calls where userIdentity.accessKeyId matches responseElements.accessKey.accessKeyId in the 24 hours after the key creation to identify all actions taken using the root access key
2. Check if the root account credentials may be compromised by reviewing sourceIPAddress and userAgent against known legitimate access patterns, then verify with the account owner if this creation was authorized
3. Search CloudTrail for IAM policy modifications, user creations, role changes, and resource deletions by userIdentity.type="Root" in the 6 hours around this event to identify unauthorized changes made using root credentials
Reference: https://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisCreateAccessKeyuserIdentity.typeisRootrequestParametersis empty
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"CreateAccessKey" |
requestParameters | is_null | field:"aws::requestParameters" kind:is_null | |
userIdentity.type | eq |
| field:"aws::userIdentity.type" kind:eq value:"Root" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
1. Query CloudTrail for all API calls where userIdentity.accessKeyId matches responseElements.accessKey.accessKeyId in the 24 hours after the key creation to identify all actions taken using the root access key
2. Check if the root account credentials may be compromised by reviewing sourceIPAddress and userAgent against known legitimate access patterns, then verify with the account owner if this creation was authorized
3. Search CloudTrail for IAM policy modifications, user creations, role changes, and resource deletions by userIdentity.type="Root" in the 6 hours around this event to identify unauthorized changes made using root credentials
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "1111",
"eventName": "CreateAccessKey",
"eventSource": "iam.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1111",
"requestParameters": null,
"responseElements": {
"accessKey": {
"accessKeyId": "1111",
"createDate": "Jan 01, 2019 0:00:00 PM",
"status": "Active"
}
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "signin.amazonaws.com",
"userIdentity": {
"accessKeyId": "1111",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:root",
"invokedBy": "signin.amazonaws.com",
"principalId": "123456789012",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "true"
}
},
"type": "Root"
}
}
Root Account Activity
#Root account activity that modifies AWS resources or configuration was detected. Read-only root events (enumeration, console reads) are excluded — only impactful root actions trigger this rule.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success
EVENT_ALLOW_LIST = {"CreateServiceLinkedRole"}
def rule(event):
return (
event.deep_get("userIdentity", "type") == "Root"
and aws_cloudtrail_success(event)
and event.deep_get("userIdentity", "invokedBy") is None
and event.get("eventType") != "AwsServiceEvent"
and event.get("eventName") not in EVENT_ALLOW_LIST
and not event.get("readOnly")
)
def dedup(event):
return event.get("sourceIPAddress", "<UNKNOWN_IP>")
def title(event):
return (
"AWS root user activity "
f"[{event.get('eventName')}] "
"in account "
f"[{event.get('recipientAccountId')}]"
)
def alert_context(event):
return {
"sourceIPAddress": event.get("sourceIPAddress"),
"userIdentityAccountId": event.deep_get("userIdentity", "accountId"),
"userIdentityArn": event.deep_get("userIdentity", "arn"),
"eventTime": event.get("eventTime"),
"mfaUsed": event.deep_get("additionalEventData", "MFAUsed"),
}
Rule specification
AnalysisType: rule
Filename: aws_root_activity.py
RuleID: "AWS.Root.Activity"
DisplayName: "Root Account Activity"
DedupPeriodMinutes: 60
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Identity & Access Management
- DemoThreatHunting
- Privilege Escalation:Valid Accounts
Reports:
CIS:
- 3.3
MITRE ATT&CK:
- TA0004:T1078
Severity: High
Description: >
Root account activity that modifies AWS resources or configuration was detected. Read-only root events (enumeration, console reads) are excluded — only impactful root actions trigger this rule.
Runbook: >
Investigate the usage of the root account. If this root activity was not authorized, immediately change the root credentials and investigate what actions the root account took.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html
SummaryAttributes:
- awsRegion
- eventName
- eventSource
- userAgent
- p_any_aws_account_ids
- p_any_aws_arns
- p_any_ip_addresses
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
userIdentity.typeisRooterrorCodeis emptyerrorMessageis emptyuserIdentity.invokedByis emptyeventTypeis notAwsServiceEventeventNameis not one ofCreateServiceLinkedRolereadOnlyis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | eq | CreateServiceLinkedRole | excludes:eventName field:"eventName" value:"CreateServiceLinkedRole" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventType | ne |
| field:"eventType" kind:ne value:"AwsServiceEvent" |
readOnly | is_null | field:"readOnly" kind:is_null | |
userIdentity.invokedBy | is_null | field:"userIdentity.invokedBy" kind:is_null | |
userIdentity.type | eq |
| field:"aws::userIdentity.type" kind:eq value:"Root" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
sourceIPAddress | |
userIdentityAccountId | userIdentity.accountId |
userIdentityArn | userIdentity.arn |
eventTime | |
mfaUsed | additionalEventData.MFAUsed |
eventName | |
recipientAccountId |
Response runbook
Investigate the usage of the root account. If this root activity was not authorized, immediately change the root credentials and investigate what actions the root account took.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"AuthenticationMethod": "AuthHeader",
"CipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
"SignatureVersion": "SigV4"
},
"awsRegion": "us-west-2",
"eventID": "1",
"eventName": "PutBucketVersioning",
"eventSource": "s3.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1",
"requestParameters": {
"VersioningConfiguration": {
"MfaDelete": "Enabled",
"Status": "Enabled",
"xmlns": "http://s3.amazonaws.com/doc/2006-03-01/"
},
"bucketName": "bucket",
"host": [
"bucket.s3.us-west-2.amazonaws.com"
],
"versioning": [
""
]
},
"responseElements": null,
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla",
"userIdentity": {
"accessKeyId": "1",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:root",
"principalId": "123456789012",
"sessionContext": {
"attributes": {
"creationDate": "2019-01-01T00:00:00Z",
"mfaAuthenticated": "false"
}
},
"type": "Root"
}
}
Root Console Login
#The root account has been logged into.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Privilege Escalation |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Console Login (Panther)
- AWS Console Login Failed During MFA Challenge (Splunk)
- AWS ConsoleLogin Failed Authentication (Sigma)
- AWS CreateLoginProfile (Splunk)
- AWS Credential Access Failed Login (Splunk)
- AWS High Number Of Failed Authentications For User (Splunk)
- AWS High Number Of Failed Authentications From Ip (Splunk)
- AWS Multiple Failed MFA Requests For User (Splunk)
Detection logic
from panther_ipinfo_helpers import geoinfo_from_ip_formatted
def rule(event):
return (
event.get("eventName") == "ConsoleLogin"
and event.deep_get("userIdentity", "type") == "Root"
and event.deep_get("responseElements", "ConsoleLogin") == "Success"
)
def title(event):
return (
"AWS root login detected from "
f"({geoinfo_from_ip_formatted(event, 'sourceIPAddress')}) "
f"in account "
f"[{event.get('recipientAccountId')}]"
)
def dedup(event):
# Each Root login should generate a unique alert
return "-".join(
[event.get("recipientAccountId"), event.get("eventName"), event.get("eventTime")]
)
def alert_context(event):
return {
"sourceIPAddress": event.get("sourceIPAddress"),
"userIdentityAccountId": event.deep_get("userIdentity", "accountId"),
"userIdentityArn": event.deep_get("userIdentity", "arn"),
"eventTime": event.get("eventTime"),
"mfaUsed": event.deep_get("additionalEventData", "MFAUsed"),
}
Rule specification
AnalysisType: rule
Filename: aws_console_root_login.py
RuleID: "AWS.Console.RootLogin"
DisplayName: "Root Console Login"
Enabled: true
DedupPeriodMinutes: 15
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Identity & Access Management
- Authentication
- DemoThreatHunting
- Privilege Escalation:Valid Accounts
Reports:
CIS:
- 3.6
MITRE ATT&CK:
- TA0004:T1078
Severity: High
Description: The root account has been logged into.
Runbook: >
Investigate the usage of the root account. If this root activity was not authorized, immediately change the root credentials and investigate what actions the root account took.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisConsoleLoginuserIdentity.typeisRootresponseElements.ConsoleLoginisSuccess
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
sourceIPAddress | |
userIdentityAccountId | userIdentity.accountId |
userIdentityArn | userIdentity.arn |
eventTime | |
mfaUsed | additionalEventData.MFAUsed |
recipientAccountId |
Response runbook
Investigate the usage of the root account. If this root activity was not authorized, immediately change the root credentials and investigate what actions the root account took.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"LoginTo": "https://console.aws.amazon.com/console/",
"MFAUsed": "No",
"MobileVersion": "No"
},
"awsRegion": "us-east-1",
"eventID": "1",
"eventName": "ConsoleLogin",
"eventSource": "signin.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsConsoleSignIn",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestParameters": null,
"responseElements": {
"ConsoleLogin": "Success"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla",
"userIdentity": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:root",
"principalId": "1111",
"type": "Root",
"userName": "root"
}
}
Root Password Changed
#Someone manually changed the Root console login password.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence |
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
# Only check password update changes
if event.get("eventName") != "PasswordUpdated":
return False
# Only check root activity
if event.deep_get("userIdentity", "type") != "Root":
return False
# Only alert if the login was a success
return event.deep_get("responseElements", "PasswordUpdated") == "Success"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_root_password_changed.py
RuleID: "AWS.CloudTrail.RootPasswordChanged"
DisplayName: "Root Password Changed"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Identity and Access Management
- Persistence:Account Manipulation
Severity: High
Reports:
MITRE ATT&CK:
- TA0003:T1098
Description: >
Someone manually changed the Root console login password.
Runbook: >
Verify that the root password change was authorized. If not, AWS support should be contacted immediately as the root account cannot be recovered through normal means and grants complete access to the account.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_passwords_change-root.html
SummaryAttributes:
- userAgent
- sourceIpAddress
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisPasswordUpdateduserIdentity.typeisRootresponseElements.PasswordUpdatedisSuccess
Indicators
These rows show field, operator, and value matches.
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Verify that the root password change was authorized. If not, AWS support should be contacted immediately as the root account cannot be recovered through normal means and grants complete access to the account.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "1111",
"eventName": "PasswordUpdated",
"eventSource": "signin.amazonaws.com",
"eventTime": "2019-01-01T00:00:00Z",
"eventType": "AwsConsoleSignIn",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "1111",
"requestParameters": null,
"responseElements": {
"PasswordUpdated": "Success"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36",
"userIdentity": {
"accesKeyId": "1111",
"accessKeyId": "",
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:root",
"principalId": "123456789012",
"type": "Root"
}
}
S3 Access Via VPC Endpoint From External IP
#Detects S3 data access through VPC endpoints from external/public IP addresses, which could indicate data exfiltration attempts. This rule can be customized with the following overrides: - S3_DATA_ACCESS_OPERATIONS: List of S3 operations to monitor
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS Detect Users with KMS keys performing encryption S3 (Splunk)
- AWS Exfiltration via Anomalous GetObject API Activity (Splunk)
- AWSCloudTrail - S3 bucket suspicious ransomware activity (Kusto)
- AWSCloudTrail - S3 Object Exfiltration from Anonymous User (Kusto)
- AWSCloudTrail - S3 object publicly exposed (Kusto)
- AWSCloudTrail - Successful brute force attack on S3 Bucket (Kusto)
- AWSCloudTrail - Suspicious command sent to EC2 (Kusto)
- Suspicious access of BEC related documents in AWS S3 buckets (Kusto)
Detection logic
import ipaddress
from panther_aws_helpers import aws_rule_context
# Define S3 data access operations
S3_DATA_ACCESS_OPERATIONS = [
"GetObject",
"GetObjectVersion",
"GetObjectAcl",
"GetObjectVersionAcl",
"PutObject",
"PutObjectAcl",
"PutObjectVersionAcl",
"CopyObject",
"DeleteObject",
"DeleteObjects",
"DeleteObjectVersion",
]
def rule(event):
# Check if this is a VPC Endpoint network activity event for S3
if (
event.get("eventType") != "AwsVpceEvent"
or event.get("eventCategory") != "NetworkActivity"
or event.get("eventSource") != "s3.amazonaws.com"
):
return False
# Focus on data access operations
if event.get("eventName") not in S3_DATA_ACCESS_OPERATIONS:
return False
# Check for external IP
source_ip = event.get("sourceIPAddress", "")
if not source_ip:
return False
try:
ip_obj = ipaddress.ip_address(source_ip)
if ip_obj.is_global:
return True
except ValueError:
# If source_ip is not a valid IP address
pass
return False
def title(event):
# Use UDM actor_user which leverages the get_actor_user helper function
actor_user = event.udm("actor_user")
source_ip = event.get("sourceIPAddress", "unknown")
bucket_name = event.deep_get("requestParameters", "bucketName", default="unknown")
return (
f"S3 Access via VPC Endpoint from External IP: [{actor_user}] from "
f"[{source_ip}] to bucket [{bucket_name}]"
)
def alert_context(event):
account_id = event.deep_get("userIdentity", "accountId", default="")
context = aws_rule_context(event)
context.update(
{
"account_id": account_id,
"principal_id": event.deep_get("userIdentity", "principalId", default="unknown"),
"actor_user": event.udm("actor_user"),
"source_ip": event.get("sourceIPAddress", "unknown"),
"event_source": event.get("eventSource", "unknown"),
"api_call": event.get("eventName", "unknown"),
"resources": event.get("resources", []),
"request_parameters": event.get("requestParameters", {}),
"config": {
"operations_monitored": S3_DATA_ACCESS_OPERATIONS,
},
}
)
return context
Rule specification
AnalysisType: rule
Filename: aws_vpce_s3_external_ip.py
RuleID: "AWS.CloudTrail.VPCE.S3ExternalIP"
DisplayName: "S3 Access Via VPC Endpoint From External IP"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Medium
Tags:
- AWS
- VPC
- S3
- Data Exfiltration
- CloudTrail
- Network Boundary Bridging
- Exfiltration Over Alternative Protocol
Description: |
Detects S3 data access through VPC endpoints from external/public IP addresses, which could indicate data exfiltration attempts.
This rule can be customized with the following overrides:
- S3_DATA_ACCESS_OPERATIONS: List of S3 operations to monitor
Runbook: |
1. Identify the principal and the specific S3 objects being accessed
2. Verify if the external IP address belongs to a legitimate service or entity
3. Check if the access pattern is expected for this user/role
4. Review the contents of the S3 objects to determine sensitivity
5. If unauthorized, determine how the principal obtained access credentials
6. Revoke access immediately if determined to be malicious
7. Consider implementing stricter bucket policies and VPC endpoint policies
Reference: https://www.wiz.io/blog/aws-vpc-endpoint-cloudtrail
SummaryAttributes:
- userIdentity.principalId
- sourceIPAddress
- eventSource
- eventName
- requestParameters
- resources
DedupPeriodMinutes: 60
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventTypeisAwsVpceEventeventCategoryisNetworkActivityeventSourceiss3.amazonaws.comeventNameis one ofGetObject,GetObjectVersion,GetObjectAcl,GetObjectVersionAcl,PutObjectsourceIPAddressis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
eventCategory | ne | NetworkActivity | excludes:eventCategory field:"eventCategory" value:"NetworkActivity" |
eventSource | ne | s3.amazonaws.com | excludes:eventSource field:"eventSource" value:"s3.amazonaws.com" |
eventType | ne | AwsVpceEvent | excludes:eventType field:"eventType" value:"AwsVpceEvent" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | in |
| field:"aws::eventName" kind:in |
sourceIPAddress | is_not_null | field:"aws::sourceIPAddress" kind:is_not_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
1. Identify the principal and the specific S3 objects being accessed
2. Verify if the external IP address belongs to a legitimate service or entity
3. Check if the access pattern is expected for this user/role
4. Review the contents of the S3 objects to determine sensitivity
5. If unauthorized, determine how the principal obtained access credentials
6. Revoke access immediately if determined to be malicious
7. Consider implementing stricter bucket policies and VPC endpoint policies
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"eventCategory": "NetworkActivity",
"eventName": "GetObject",
"eventSource": "s3.amazonaws.com",
"eventTime": "2023-01-01T12:00:00Z",
"eventType": "AwsVpceEvent",
"recipientAccountId": "012345678901",
"requestParameters": {
"bucketName": "sensitive-data-bucket",
"key": "confidential/file.pdf"
},
"resources": [
{
"ARN": "arn:aws:s3:::sensitive-data-bucket/confidential/file.pdf",
"type": "AWS::S3::Object"
}
],
"sourceIPAddress": "8.8.8.8",
"userIdentity": {
"accountId": "012345678901",
"principalId": "AIDAEXAMPLE",
"type": "IAMUser"
},
"vpcEndpointAccountId": "012345678901",
"vpcEndpointId": "vpce-EXAMPLE08c1b6b9b7"
}
S3 Bucket Deleted
#A S3 Bucket, Policy, or Website was deleted
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
# Capture DeleteBucket, DeleteBucketPolicy, DeleteBucketWebsite
return event.get("eventName").startswith("DeleteBucket") and aws_cloudtrail_success(event)
def helper_strip_role_session_id(user_identity_arn):
# The Arn structure is arn:aws:sts::123456789012:assumed-role/RoleName/<sessionId>
arn_parts = user_identity_arn.split("/")
if arn_parts:
return "/".join(arn_parts[:2])
return user_identity_arn
def dedup(event):
user_identity = event.get("userIdentity", {})
if user_identity.get("type") == "AssumedRole":
return helper_strip_role_session_id(user_identity.get("arn", ""))
return user_identity.get("arn", "<NO_ARN_FOUND>")
def title(event):
return f"{event.deep_get('userIdentity', 'type')} [{dedup(event)}] destroyed a bucket"
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_s3_bucket_deleted.py
RuleID: "AWS.S3.BucketDeleted"
DisplayName: "S3 Bucket Deleted"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Impact:Data Destruction
Reports:
MITRE ATT&CK:
- TA0040:T1485
Stratus Red Team:
- aws.defense-evasion.cloudtrail-lifecycle-rule
- aws.exfiltration.s3-backdoor-bucket-policy
Severity: Info
Description: A S3 Bucket, Policy, or Website was deleted
Runbook: Explore if this bucket deletion was potentially destructive
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/DeletingObjects.html
SummaryAttributes:
- sourceIpAddress
- userAgent
- recipientAccountId
- vpcEndpointId
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNamestarts withDeleteBucketerrorCodeis emptyerrorMessageis empty
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | starts_with |
| field:"aws::eventName" kind:starts_with value:"DeleteBucket" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
type | userIdentity.type |
Response runbook
Explore if this bucket deletion was potentially destructive
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"AuthenticationMethod": "AuthHeader",
"CipherSuite": "ECDHE-RSA-AES128-SHA",
"SignatureVersion": "SigV4",
"vpcEndpointId": "vpce-aaa333c9"
},
"awsRegion": "us-east-2",
"errorCode": "",
"eventID": "6795ef5c-7777-4444-8888-cabb7f252bd3",
"eventName": "DeleteBucket",
"eventSource": "s3.amazonaws.com",
"eventTime": "2020-02-14T00:43:54Z",
"eventType": "AwsApiCall",
"eventVersion": "1.05",
"recipientAccountId": "123456789012",
"requestID": "EEEE5AAAAAA44444",
"requestParameters": {
"bucketName": "secrets",
"host": [
"s3-us-east-2.amazonaws.com"
]
},
"responseElements": null,
"sourceIPAddress": "157.130.196.214",
"userAgent": "[S3Console/0.4, aws-internal/3 aws-sdk-java/1.11.666 Linux/4.9.184-0.1.ac.235.83.329.metal1.x86_64 OpenJDK_64-Bit_Server_VM/25.232-b09 java/1.8.0_232 vendor/Oracle_Corporation]",
"userIdentity": {
"accessKeyId": "AAAAAAAAAAAAAAAAAAAAA",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/BucketAdministrator/user_name",
"principalId": "AAAAAAAAAAAAAAAAAAAAA:user_name",
"sessionContext": {
"attributes": {
"creationDate": "2020-02-14T00:11:28Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/BucketAdministrator",
"principalId": "AAAAAAAAAAAAAAAAAAAAA",
"type": "Role",
"userName": "BucketAdministrator"
}
},
"type": "AssumedRole"
},
"vpcEndpointId": "vpce-aaa333c9"
}
S3 Bucket Encryption Deleted
#Detects when S3 bucket encryption configuration is deleted, which could expose data to unauthorized access or indicate ransomware preparation activity.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment | |
| Impact |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS S3 Bucket Configuration Deletion (Elastic)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "s3.amazonaws.com"
and event.get("eventName") == "DeleteBucketEncryption"
)
def title(event):
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"deleted bucket encryption for bucket "
f"[{event.deep_get('requestParameters', 'bucketName')}] bucket"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_s3_delete_bucket_encryption.py
RuleID: "AWS.S3.DeleteBucketEncryption"
DisplayName: "S3 Bucket Encryption Deleted"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- S3
- Defense Evasion
- Impact:Data Destruction
Reports:
MITRE ATT&CK:
- TA0005:T1562
- TA0040:T1485
Severity: Medium
Status: Experimental
Description: Detects when S3 bucket encryption configuration is deleted, which could expose data to unauthorized access or indicate ransomware preparation activity.
Runbook: |
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after the encryption deletion to identify if this is part of a larger attack pattern
2. Check if this user has performed DeleteBucketEncryption on this bucket in the past 90 days to determine if this is normal administrative activity
3. Find other alerts with rule IDs AWS.S3.DisableBucketLogging, AWS.S3.SuspendVersioning, or AWS.S3.DisableMfaDelete for the same bucket in the past 7 days to detect coordinated security control disabling
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-encryption.html
SummaryAttributes:
- sourceIpAddress
- userAgent
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceiss3.amazonaws.comeventNameisDeleteBucketEncryption
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"DeleteBucketEncryption" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"s3.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after the encryption deletion to identify if this is part of a larger attack pattern
2. Check if this user has performed DeleteBucketEncryption on this bucket in the past 90 days to determine if this is normal administrative activity
3. Find other alerts with rule IDs AWS.S3.DisableBucketLogging, AWS.S3.SuspendVersioning, or AWS.S3.DisableMfaDelete for the same bucket in the past 7 days to detect coordinated security control disabling
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "12345678-1234-1234-1234-111111111111",
"eventName": "DeleteBucketEncryption",
"eventSource": "s3.amazonaws.com",
"eventTime": "2024-01-15T10:45:23Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "111111111111",
"requestID": "ABC123DEF456",
"requestParameters": {
"bucketName": "sensitive-data-bucket",
"encryption": "",
"host": "sample-bucket-pensive-jones.s3.amazonaws.com"
},
"responseElements": null,
"sourceIPAddress": "1.2.3.4",
"userAgent": "aws-cli/2.13.0 Python/3.11.4 Linux/5.10.0-1234-aws exe/x86_64.ubuntu.22",
"userIdentity": {
"accessKeyId": "ASIA-MOCKACCESSKEYID-1",
"accountId": "111111111111",
"arn": "arn:aws:sts::111111111111:assumed-role/sample-role-dreamy-yonath/sample-role-brave-yalow-role-intelligent-brahmagupta-role-beautiful-keldysh-role-happy-easley-role-bold-buck",
"principalId": "AAAAAAAAAAAAAAAAAAAAA:user_name",
"sessionContext": {
"attributes": {
"creationDate": "2024-01-15T10:30:00Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111111111111",
"arn": "arn:aws:iam::111111111111:role/sample-role-dreamy-yonath",
"principalId": "AAAAAAAAAAAAAAAAAAAAA",
"type": "Role",
"userName": "AdminRole"
}
},
"type": "AssumedRole"
},
"vpcEndpointId": "vpce-1a2b3c4d"
}
S3 Bucket Logging Disabled
#Detects when server access logging is disabled on an S3 bucket, removing audit trail capabilities that could indicate ransomware preparation activity or an attempt to evade detection.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment | |
| Impact |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event PutBucketLogging: Sets the logging parameters for a specified S3 bucket. |
Rules detecting the same action
These rules filter on the same operation.
- AWS S3 Bucket Server Access Logging Disabled (Elastic)
- AWS S3 Data Management Tampering (Sigma)
- AWS S3 Security Control Disabling (Panther)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "s3.amazonaws.com"
and event.get("eventName") == "PutBucketLogging"
and event.deep_get("requestParameters", "logging") == ""
)
def title(event):
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"disabled bucket logging for bucket "
f"[{event.deep_get('requestParameters', 'bucketName')}]"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_s3_disable_bucket_logging.py
RuleID: "AWS.S3.DisableBucketLogging"
DisplayName: "S3 Bucket Logging Disabled"
Enabled: true
CreateAlert: false
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Defense Evasion
- Impact:Data Destruction
Reports:
MITRE ATT&CK:
- TA0005:T1562
- TA0040:T1485
Severity: Low
Description: Detects when server access logging is disabled on an S3 bucket, removing audit trail capabilities that could indicate ransomware preparation activity or an attempt to evade detection.
Runbook: |
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after the bucket logging was disabled to identify if this is part of a larger attack pattern
2. Check if this user has modified bucket logging configurations in the past 90 days to determine if this is normal administrative activity
3. Find other alerts with rule IDs AWS.S3.SuspendVersioning, AWS.S3.DisableMfaDelete, or AWS.S3.DeleteBucketEncryption for the same bucket in the past 7 days to detect coordinated security control disabling
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerLogs.html
SummaryAttributes:
- sourceIpAddress
- userAgent
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceiss3.amazonaws.comeventNameisPutBucketLoggingrequestParameters.loggingis""
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"PutBucketLogging" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"s3.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after the bucket logging was disabled to identify if this is part of a larger attack pattern
2. Check if this user has modified bucket logging configurations in the past 90 days to determine if this is normal administrative activity
3. Find other alerts with rule IDs AWS.S3.SuspendVersioning, AWS.S3.DisableMfaDelete, or AWS.S3.DeleteBucketEncryption for the same bucket in the past 7 days to detect coordinated security control disabling
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"additionalEventData": {
"AuthenticationMethod": "AuthHeader",
"CipherSuite": "TLS_AES_128_GCM_SHA256",
"SignatureVersion": "SigV4",
"bytesTransferredIn": 108,
"bytesTransferredOut": 0,
"x-amz-id-2": "abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ12345678901234567890=="
},
"awsRegion": "us-west-2",
"eventCategory": "Management",
"eventID": "12345678-1234-1234-1234-111111111111",
"eventName": "PutBucketLogging",
"eventSource": "s3.amazonaws.com",
"eventTime": "2024-01-15T10:45:23Z",
"eventType": "AwsApiCall",
"eventVersion": "1.11",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "111111111111",
"requestID": "ABC123DEF456",
"requestParameters": {
"BucketLoggingStatus": {
"xmlns": "http://s3.amazonaws.com/doc/2006-03-01/"
},
"Host": "sample-bucket-great-proskuriakova.s3.us-west-2.amazonaws.com",
"bucketName": "critical-data-bucket",
"logging": ""
},
"resources": [
{
"ARN": "arn:aws:s3:::sample-bucket-great-proskuriakova",
"accountId": "111111111111",
"type": "AWS::S3::Bucket"
}
],
"responseElements": null,
"sourceIPAddress": "1.2.3.4",
"tlsDetails": {
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "sample-bucket-great-proskuriakova.s3.us-west-2.amazonaws.com",
"tlsVersion": "TLSv1.3"
},
"userAgent": "[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.2.3.4 Safari/537.36]",
"userIdentity": {
"accessKeyId": "ASIA-MOCKACCESSKEYID-1",
"accountId": "111111111111",
"arn": "arn:aws:sts::111111111111:assumed-role/sample-role-dreamy-yonath/sample-role-brave-yalow-role-intelligent-brahmagupta-role-beautiful-keldysh-role-happy-easley-role-bold-buck",
"principalId": "AAAAAAAAAAAAAAAAAAAAA:user_name",
"sessionContext": {
"attributes": {
"creationDate": "2024-01-15T10:30:00Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111111111111",
"arn": "arn:aws:iam::111111111111:role/sample-role-quirky-greider/sso.amazonaws.com/us-west-2/sample-role-dreamy-yonath",
"principalId": "AAAAAAAAAAAAAAAAAAAAA",
"type": "Role",
"userName": "AdminRole"
}
},
"type": "AssumedRole"
}
}
S3 Bucket Policy Confused Deputy Protection for Service Principals
#Ensures that S3 bucket policies with service principals include conditions to prevent the confused deputy problem.
Detection logic
import json
REQUIRED_CONDITIONS = {
"aws:SourceArn",
"aws:SourceAccount",
"aws:SourceOrgID",
"aws:SourceOrgPaths",
}
def policy(resource):
bucket_policy = resource.get("Policy")
if bucket_policy is None:
return True # Pass if there is no bucket policy
policy_statements = json.loads(bucket_policy).get("Statement", [])
for statement in policy_statements:
# Check if the statement includes a service principal and allows access
principal = statement.get("Principal", {})
if "Service" in principal and statement["Effect"] == "Allow":
conditions = statement.get("Condition", {})
# Flatten nested condition keys (e.g., inside "StringEquals")
flat_condition_keys = set()
for condition in conditions.values():
if isinstance(condition, dict):
flat_condition_keys.update(condition.keys())
# Check if any required condition key is present
if not {str.casefold(x) for x in REQUIRED_CONDITIONS} & {
str.casefold(x) for x in flat_condition_keys
}:
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_s3_bucket_policy_confused_deputy.py
PolicyID: "AWS.S3.Bucket.PolicyConfusedDeputyProtection"
DisplayName: "S3 Bucket Policy Confused Deputy Protection for Service Principals"
Enabled: true
ResourceTypes:
- AWS.S3.Bucket
Tags:
- AWS
- Security Control
- Best Practices
Severity: High
Description: >
Ensures that S3 bucket policies with service principals include conditions to prevent the confused deputy problem.
Runbook: |
Update the bucket policy to include conditions such as aws:SourceArn, aws:SourceAccount,
aws:SourceOrgID, or aws:SourceOrgPaths when a service principal is specified.
Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html
Stages and Predicates
Flags AWS.S3.Bucket resources when the condition below holds.
Condition
Policyis present
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Policy | is_null | excludes:Policy |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Policy | is_not_null | field:"Policy" kind:is_not_null |
Response runbook
Update the bucket policy to include conditions such as aws:SourceArn, aws:SourceAccount,
aws:SourceOrgID, or aws:SourceOrgPaths when a service principal is specified.
S3 Bucket Replication Deleted
#Detects when S3 bucket replication configuration is deleted, which could prevent data backup and indicate ransomware preparation activity.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment | |
| Impact |
Telemetry coverage
Rules detecting the same action
These rules filter on the same operation.
- AWS S3 Bucket Configuration Deletion (Elastic)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "s3.amazonaws.com"
and event.get("eventName") == "DeleteBucketReplication"
)
def title(event):
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"deleted bucket replication for bucket "
f"[{event.deep_get('requestParameters', 'bucketName')}] bucket"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_s3_delete_bucket_replication.py
RuleID: "AWS.S3.DeleteBucketReplication"
DisplayName: "S3 Bucket Replication Deleted"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Defense Evasion
- Impact:Data Destruction
Reports:
MITRE ATT&CK:
- TA0005:T1562
- TA0040:T1485
Severity: Medium
Description: Detects when S3 bucket replication configuration is deleted, which could prevent data backup and indicate ransomware preparation activity.
Runbook: |
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after the replication deletion to establish full activity context
2. Check if this user has historically managed replication settings for this bucket in the past 90 days to verify if this is authorized administrative work
3. Find other alerts indicating security control disabling (DeleteBucketEncryption, SuspendVersioning, DisableBucketLogging) for the same bucket in the past 7 days to identify ransomware preparation patterns
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/replication.html
SummaryAttributes:
- sourceIpAddress
- userAgent
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceiss3.amazonaws.comeventNameisDeleteBucketReplication
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"DeleteBucketReplication" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"s3.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after the replication deletion to establish full activity context
2. Check if this user has historically managed replication settings for this bucket in the past 90 days to verify if this is authorized administrative work
3. Find other alerts indicating security control disabling (DeleteBucketEncryption, SuspendVersioning, DisableBucketLogging) for the same bucket in the past 7 days to identify ransomware preparation patterns
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "12345678-1234-1234-1234-111111111111",
"eventName": "DeleteBucketReplication",
"eventSource": "s3.amazonaws.com",
"eventTime": "2024-01-15T10:45:23Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "111111111111",
"requestID": "ABC123DEF456",
"requestParameters": {
"bucketName": "production-backup-bucket",
"host": "sample-bucket-serene-pike.s3.amazonaws.com",
"replication": ""
},
"responseElements": null,
"sourceIPAddress": "1.2.3.4",
"userAgent": "aws-cli/2.13.0 Python/3.11.4 Linux/5.10.0-1234-aws exe/x86_64.ubuntu.22",
"userIdentity": {
"accessKeyId": "ASIA-MOCKACCESSKEYID-1",
"accountId": "111111111111",
"arn": "arn:aws:sts::111111111111:assumed-role/sample-role-dreamy-yonath/sample-role-brave-yalow-role-intelligent-brahmagupta-role-beautiful-keldysh-role-happy-easley-role-bold-buck",
"principalId": "AAAAAAAAAAAAAAAAAAAAA:user_name",
"sessionContext": {
"attributes": {
"creationDate": "2024-01-15T10:30:00Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111111111111",
"arn": "arn:aws:iam::111111111111:role/sample-role-dreamy-yonath",
"principalId": "AAAAAAAAAAAAAAAAAAAAA",
"type": "Role",
"userName": "AdminRole"
}
},
"type": "AssumedRole"
},
"vpcEndpointId": "vpce-1a2b3c4d"
}
S3 Bucket Versioning Suspended
#Detects when S3 bucket versioning is suspended or disabled, which removes the ability to recover previous versions of objects and is a common precursor to ransomware attacks or data destruction.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment | |
| Impact |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event PutBucketVersioning: Sets the versioning state for a specified S3 bucket. |
Rules detecting the same action
These rules filter on the same operation.
- AWS S3 Bucket Versioning Disable (Sigma)
- AWS S3 Object Versioning Suspended (Elastic)
- AWS S3 Security Control Disabling (Panther)
- S3 MFA Delete Disabled (Panther)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "s3.amazonaws.com"
and event.get("eventName") == "PutBucketVersioning"
and event.deep_get("requestParameters", "VersioningConfiguration", "Status")
in ("Suspended", "Disabled")
)
def title(event):
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"suspended object versioning in bucket "
f"[{event.deep_get('requestParameters', 'bucketName')}]"
)
def alert_context(event):
context = aws_rule_context(event)
context["bucketName"] = event.deep_get(
"requestParameters", "bucketName", default="UNKNOWN_BUCKET"
)
return context
Rule specification
AnalysisType: rule
Filename: aws_s3_suspend_versioning.py
RuleID: "AWS.S3.SuspendVersioning"
DisplayName: "S3 Bucket Versioning Suspended"
Enabled: true
CreateAlert: false
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Defense Evasion
- Impact:Data Destruction
Reports:
MITRE ATT&CK:
- TA0005:T1562
- TA0040:T1485
Severity: Low
Description: Detects when S3 bucket versioning is suspended or disabled, which removes the ability to recover previous versions of objects and is a common precursor to ransomware attacks or data destruction.
Runbook: |
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after versioning was suspended
2. Find any DeleteObject or DeleteObjects events on this bucket in the 6 hours after versioning was suspended to detect potential data destruction attempts
3. Search for other security control changes (DisableBucketLogging, DisableMfaDelete, DeleteBucketEncryption) on the same bucket in the past 7 days to identify coordinated attack patterns
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html
SummaryAttributes:
- sourceIpAddress
- userAgent
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceiss3.amazonaws.comeventNameisPutBucketVersioningrequestParameters.VersioningConfiguration.Statusis one ofSuspended,Disabled
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"PutBucketVersioning" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"s3.amazonaws.com" |
requestParameters.VersioningConfiguration.Status | in |
| field:"requestParameters.VersioningConfiguration.Status" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after versioning was suspended
2. Find any DeleteObject or DeleteObjects events on this bucket in the 6 hours after versioning was suspended to detect potential data destruction attempts
3. Search for other security control changes (DisableBucketLogging, DisableMfaDelete, DeleteBucketEncryption) on the same bucket in the past 7 days to identify coordinated attack patterns
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "12345678-1234-1234-1234-111111111111",
"eventName": "PutBucketVersioning",
"eventSource": "s3.amazonaws.com",
"eventTime": "2024-01-15T10:45:23Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "111111111111",
"requestID": "ABC123DEF456",
"requestParameters": {
"VersioningConfiguration": {
"Status": "Suspended"
},
"bucketName": "important-files-bucket",
"host": "sample-bucket-lucid-jang.s3.amazonaws.com"
},
"responseElements": null,
"sourceIPAddress": "1.2.3.4",
"userAgent": "aws-cli/2.13.0 Python/3.11.4 Linux/5.10.0-1234-aws exe/x86_64.ubuntu.22",
"userIdentity": {
"accessKeyId": "ASIA-MOCKACCESSKEYID-1",
"accountId": "111111111111",
"arn": "arn:aws:sts::111111111111:assumed-role/sample-role-dreamy-yonath/sample-role-brave-yalow-role-intelligent-brahmagupta-role-beautiful-keldysh-role-happy-easley-role-bold-buck",
"principalId": "AAAAAAAAAAAAAAAAAAAAA:user_name",
"sessionContext": {
"attributes": {
"creationDate": "2024-01-15T10:30:00Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111111111111",
"arn": "arn:aws:iam::111111111111:role/sample-role-dreamy-yonath",
"principalId": "AAAAAAAAAAAAAAAAAAAAA",
"type": "Role",
"userName": "AdminRole"
}
},
"type": "AssumedRole"
},
"vpcEndpointId": "vpce-1a2b3c4d"
}
S3 MFA Delete Disabled
#Detects when MFA Delete is disabled on an S3 bucket, removing an important security control that prevents accidental or malicious deletion of versioned objects and could indicate ransomware preparation activity.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment | |
| Impact |
Telemetry coverage
| Platform | Record / event type |
|---|---|
| AWS | CloudTrail event PutBucketVersioning: Sets the versioning state for a specified S3 bucket. |
Rules detecting the same action
These rules filter on the same operation.
- AWS S3 Bucket Versioning Disable (Sigma)
- AWS S3 Object Versioning Suspended (Elastic)
- AWS S3 Security Control Disabling (Panther)
- S3 Bucket Versioning Suspended (Panther)
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "s3.amazonaws.com"
and event.get("eventName") == "PutBucketVersioning"
and event.deep_get("requestParameters", "VersioningConfiguration", "MfaDelete")
== "Disabled"
)
def title(event):
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"disabled MFA Delete feature for bucket "
f"[{event.deep_get('requestParameters', 'bucketName')}] "
)
def alert_context(event):
context = aws_rule_context(event)
context["bucketName"] = event.deep_get(
"requestParameters", "bucketName", default="UNKNOWN_BUCKET"
)
return context
Rule specification
AnalysisType: rule
Filename: aws_s3_disable_mfa_delete.py
RuleID: "AWS.S3.DisableMfaDelete"
DisplayName: "S3 MFA Delete Disabled"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Defense Evasion
- Impact:Data Destruction
Reports:
MITRE ATT&CK:
- TA0005:T1562
- TA0040:T1485
Severity: Low
Description: Detects when MFA Delete is disabled on an S3 bucket, removing an important security control that prevents accidental or malicious deletion of versioned objects and could indicate ransomware preparation activity.
Runbook: |
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after MFA Delete was disabled
2. Find any DeleteObject or DeleteObjects events on this bucket in the 6 hours after MFA Delete was disabled to check if bulk deletion occurred
3. Search for other security control changes (DisableBucketLogging, SuspendVersioning, DeleteBucketEncryption) on the same bucket in the past 7 days to identify ransomware preparation patterns
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/MultiFactorAuthenticationDelete.html
SummaryAttributes:
- sourceIpAddress
- userAgent
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceiss3.amazonaws.comeventNameisPutBucketVersioningrequestParameters.VersioningConfiguration.MfaDeleteisDisabled
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"PutBucketVersioning" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"s3.amazonaws.com" |
requestParameters.VersioningConfiguration.MfaDelete | eq |
| field:"requestParameters.VersioningConfiguration.MfaDelete" kind:eq value:"Disabled" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after MFA Delete was disabled
2. Find any DeleteObject or DeleteObjects events on this bucket in the 6 hours after MFA Delete was disabled to check if bulk deletion occurred
3. Search for other security control changes (DisableBucketLogging, SuspendVersioning, DeleteBucketEncryption) on the same bucket in the past 7 days to identify ransomware preparation patterns
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "12345678-1234-1234-1234-111111111111",
"eventName": "PutBucketVersioning",
"eventSource": "s3.amazonaws.com",
"eventTime": "2024-01-15T10:45:23Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "111111111111",
"requestID": "ABC123DEF456",
"requestParameters": {
"VersioningConfiguration": {
"MfaDelete": "Disabled",
"Status": "Enabled"
},
"bucketName": "critical-data-bucket",
"host": "sample-bucket-lucid-kowalevski.s3.amazonaws.com"
},
"responseElements": null,
"sourceIPAddress": "1.2.3.4",
"userAgent": "aws-cli/2.13.0 Python/3.11.4 Linux/5.10.0-1234-aws exe/x86_64.ubuntu.22",
"userIdentity": {
"accessKeyId": "ASIA-MOCKACCESSKEYID-1",
"accountId": "111111111111",
"arn": "arn:aws:sts::111111111111:assumed-role/sample-role-dreamy-yonath/sample-role-brave-yalow-role-intelligent-brahmagupta-role-beautiful-keldysh-role-happy-easley-role-bold-buck",
"principalId": "AAAAAAAAAAAAAAAAAAAAA:user_name",
"sessionContext": {
"attributes": {
"creationDate": "2024-01-15T10:30:00Z",
"mfaAuthenticated": "true"
},
"sessionIssuer": {
"accountId": "111111111111",
"arn": "arn:aws:iam::111111111111:role/sample-role-dreamy-yonath",
"principalId": "AAAAAAAAAAAAAAAAAAAAA",
"type": "Role",
"userName": "AdminRole"
}
},
"type": "AssumedRole"
},
"vpcEndpointId": "vpce-1a2b3c4d"
}
S3 Object Encrypted with External KMS Key
#Detects when an S3 object is copied with a KMS key belonging to an account ID different than the bucket owner's account ID. This technique is used in S3 ransomware attacks where attackers encrypt objects with their own KMS key from an attacker-controlled AWS account, making the data inaccessible to the original owner. This is often a precursor to ransom demands or permanent data loss.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Impact |
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
if event.get("eventName") != "CopyObject" or not aws_cloudtrail_success(event):
return False
kms_key_arn = event.deep_get(
"requestParameters",
"x-amz-server-side-encryption-aws-kms-key-id",
default="<UNKNOWN_KEY_ID>",
)
if kms_key_arn.startswith("arn:aws:kms:"):
# Extract account ID from KMS key ARN (format: arn:aws:kms:region:account:key/key-id)
kms_parts = kms_key_arn.split(":")
if len(kms_parts) >= 5:
kms_account_id = kms_parts[4]
bucket_account_id = event.get("recipientAccountId", "")
# Alert on cross-account KMS key usage
if kms_account_id != bucket_account_id:
return True
return False
def title(event):
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"encrypted an object in bucket "
f"[{event.deep_get('requestParameters', 'bucketName')}] "
f"with a KMS key belonging to a different account ID "
f"than the account owner ID"
)
def alert_context(event):
context = aws_rule_context(event)
context["bucketName"] = event.deep_get(
"requestParameters", "bucketName", default="<UNKNOWN_BUCKET>"
)
context["objectKey"] = event.deep_get("requestParameters", "key", default="<UNKNOWN_KEY>")
kms_key_arn = event.deep_get(
"requestParameters",
"x-amz-server-side-encryption-aws-kms-key-id",
default="<UNKNOWN_KEY_ARN>",
)
context["kmsKeyId"] = kms_key_arn
# Add cross-account indicator
if kms_key_arn:
kms_parts = kms_key_arn.split(":")
if len(kms_parts) >= 5:
kms_account_id = kms_parts[4]
bucket_account_id = event.get("recipientAccountId", "")
context["isCrossAccountKms"] = kms_account_id != bucket_account_id
context["kmsAccountId"] = kms_account_id
context["bucketAccountId"] = bucket_account_id
context["encryption"] = event.deep_get(
"requestParameters", "x-amz-server-side-encryption", default="<UNKNOWN_ENCRYPTION>"
)
return context
Rule specification
AnalysisType: rule
Filename: aws_s3_copy_object_cross_account_kms.py
RuleID: "AWS.S3.CopyObject.CrossAccount.Encryption.KMS"
DisplayName: "S3 Object Encrypted with External KMS Key"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- S3
- Ransomware
- Impact:Data Destruction
Reports:
MITRE ATT&CK:
- TA0040:T1486 # Impact: Data Encrypted for Impact
Severity: High
Description: >
Detects when an S3 object is copied with a KMS key belonging to an account ID different than
the bucket owner's account ID. This technique is used in S3
ransomware attacks where attackers encrypt objects with their own KMS key from
an attacker-controlled AWS account, making the data inaccessible to the original
owner. This is often a precursor to ransom demands or permanent data loss.
Runbook: |
1. Query CloudTrail for all CopyObject events by the userIdentity:arn in the 24 hours before and after the alert to identify all affected objects in the requestParameters:bucketName
2. Check if the KMS key ARN from resources field belongs to an external account ID that appears in any legitimate cross-account operations in the past 90 days
3. Find all S3 GetObject and ListBucket events by this user on the source bucket in the 1 hour before the first CopyObject to check if the attacker performed reconnaissance
Reference: https://rhinosecuritylabs.com/aws/s3-ransomware-part-1-attack-vector/
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventNameisCopyObjecterrorCodeis emptyerrorMessageis emptyrequestParameters.x-amz-server-side-encryption-aws-kms-key-idstarts witharn:aws:kms:
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage | |
eventName | ne | CopyObject | excludes:eventName field:"eventName" value:"CopyObject" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
requestParameters.x-amz-server-side-encryption-aws-kms-key-id | starts_with |
| field:"requestParameters.x-amz-server-side-encryption-aws-kms-key-id" kind:starts_with value:"arn:aws:kms:" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
1. Query CloudTrail for all CopyObject events by the userIdentity:arn in the 24 hours before and after the alert to identify all affected objects in the requestParameters:bucketName
2. Check if the KMS key ARN from resources field belongs to an external account ID that appears in any legitimate cross-account operations in the past 90 days
3. Find all S3 GetObject and ListBucket events by this user on the source bucket in the 1 hour before the first CopyObject to check if the attacker performed reconnaissance
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "12345678-1234-1234-1234-111111111111",
"eventName": "CopyObject",
"eventSource": "s3.amazonaws.com",
"eventTime": "2024-01-15T10:45:23Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": false,
"readOnly": false,
"recipientAccountId": "111111111111",
"requestID": "ABC123DEF456",
"requestParameters": {
"bucketName": "victim-data-bucket",
"key": "important-file.txt",
"x-amz-copy-source": "victim-data-bucket/important-file.txt",
"x-amz-server-side-encryption": "aws:kms",
"x-amz-server-side-encryption-aws-kms-key-id": "arn:aws:kms:us-east-1:999999999999:key/attacker-key-id"
},
"resources": [
{
"ARN": "arn:aws:s3:::sample-bucket-quizzical-yalow/important-file.txt",
"type": "AWS::S3::Object"
},
{
"ARN": "arn:aws:kms:us-east-1:999999999999:key/attacker-key-id",
"accountId": "999999999999",
"type": "AWS::KMS::Key"
}
],
"responseElements": null,
"sourceIPAddress": "1.2.3.4",
"userAgent": "aws-cli/2.13.0 Python/3.11.4",
"userIdentity": {
"accessKeyId": "ASIA-MOCKACCESSKEYID-1",
"accountId": "111111111111",
"arn": "arn:aws:sts::111111111111:assumed-role/sample-role-elastic-rhodes/sample-role-brave-yalow-role-jolly-banzai-role-intelligent-brahmagupta-role-admiring-cori-role-beautiful-keldysh-role-hopeful-chaplygin",
"principalId": "AAAAAAAAAAAAAAAAAAAAA:attacker",
"type": "AssumedRole"
}
}
S3 Public Access Block Deleted
#Detects when S3 bucket public access block configuration is deleted, which could allow unauthorized public access to sensitive data or indicate preparation for data exfiltration.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Defense Impairment |
Telemetry coverage
Detection logic
from panther_aws_helpers import aws_cloudtrail_success, aws_rule_context
def rule(event):
return (
aws_cloudtrail_success(event)
and event.get("eventSource") == "s3.amazonaws.com"
and event.get("eventName") == "DeleteBucketPublicAccessBlock"
)
def title(event):
return (
f"[AWS.CloudTrail] User [{event.udm('actor_user')}] "
f"deleted public access block for bucket "
f"[{event.deep_get('requestParameters', 'bucketName')}] bucket"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_s3_delete_public_access_block.py
RuleID: "AWS.S3.DeletePublicAccessBlock"
DisplayName: "S3 Public Access Block Deleted"
Enabled: true
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Defense Evasion
- Initial Access
Reports:
MITRE ATT&CK:
- TA0005:T1562
- TA0001:T1190
Severity: Medium
Status: Experimental
Description: Detects when S3 bucket public access block configuration is deleted, which could allow unauthorized public access to sensitive data or indicate preparation for data exfiltration.
Runbook: |
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after the public access block deletion
2. Find any PutBucketPolicy or PutBucketAcl events on this bucket in the 1 hour after the deletion to check if the bucket or objects were made public
3. Search for GetObject events from public IP addresses on this bucket in the 6 hours after the deletion to detect unauthorized data access
Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html
SummaryAttributes:
- sourceIpAddress
- userAgent
- recipientAccountId
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
errorCodeis emptyerrorMessageis emptyeventSourceiss3.amazonaws.comeventNameisDeleteBucketPublicAccessBlock
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
errorCode | is_not_null | excludes:errorCode | |
errorMessage | is_not_null | excludes:errorMessage |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"DeleteBucketPublicAccessBlock" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"s3.amazonaws.com" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
actor_user | |
bucketName | requestParameters.bucketName |
Response runbook
1. Query CloudTrail for all S3 API calls by the userIdentity:arn on the requestParameters:bucketName in the 24 hours before and after the public access block deletion
2. Find any PutBucketPolicy or PutBucketAcl events on this bucket in the 1 hour after the deletion to check if the bucket or objects were made public
3. Search for GetObject events from public IP addresses on this bucket in the 6 hours after the deletion to detect unauthorized data access
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventID": "12345678-1234-1234-1234-111111111111",
"eventName": "DeleteBucketPublicAccessBlock",
"eventSource": "s3.amazonaws.com",
"eventTime": "2024-01-15T10:45:23Z",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "111111111111",
"requestID": "ABC123DEF456",
"requestParameters": {
"bucketName": "corporate-documents",
"host": "sample-bucket-sweet-kepler.s3.amazonaws.com",
"publicAccessBlock": ""
},
"responseElements": null,
"sourceIPAddress": "1.2.3.4",
"userAgent": "aws-cli/2.13.0 Python/3.11.4 Linux/5.10.0-1234-aws exe/x86_64.ubuntu.22",
"userIdentity": {
"accessKeyId": "ASIA-MOCKACCESSKEYID-1",
"accountId": "111111111111",
"arn": "arn:aws:sts::111111111111:assumed-role/sample-role-dreamy-yonath/sample-role-brave-yalow-role-intelligent-brahmagupta-role-beautiful-keldysh-role-happy-easley-role-bold-buck",
"principalId": "AAAAAAAAAAAAAAAAAAAAA:user_name",
"sessionContext": {
"attributes": {
"creationDate": "2024-01-15T10:30:00Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "111111111111",
"arn": "arn:aws:iam::111111111111:role/sample-role-dreamy-yonath",
"principalId": "AAAAAAAAAAAAAAAAAAAAA",
"type": "Role",
"userName": "AdminRole"
}
},
"type": "AssumedRole"
},
"vpcEndpointId": "vpce-1a2b3c4d"
}
Sensitive API Calls Via VPC Endpoint
#Detects sensitive or unusual API calls that might indicate lateral movement, reconnaissance, or other malicious activities through VPC Endpoints. Only available for CloudTrail, EC2, KMS, S3, and Secrets Manager services.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence | |
| Defense Impairment | |
| Discovery |
Rules detecting the same action
These rules filter on the same operation.
- AWS Discovery API Calls via CLI from a Single Resource (Elastic)
- AWS S3 Bucket Enumeration or Brute Force (Elastic)
- Query.EC2.CRUD.Activity.Role (Panther)
- Query.EC2.CRUD.Activity.Useragent (Panther)
Detection logic
from panther_aws_helpers import aws_rule_context
# Define sensitive API calls to monitor as a constant
SENSITIVE_APIS = {
"ec2.amazonaws.com": [
"DescribeInstances",
"DescribeNetworkInterfaces",
"CreateKeyPair",
"ImportKeyPair",
"RunInstances",
],
"kms.amazonaws.com": ["Decrypt", "GenerateDataKey", "CreateKey", "ScheduleKeyDeletion"],
"secretsmanager.amazonaws.com": [
"GetSecretValue",
"CreateSecret",
"PutSecretValue",
"DeleteSecret",
],
"s3.amazonaws.com": ["ListAllMyBuckets", "DeleteBucketPolicy", "PutBucketPolicy"],
"cloudtrail.amazonaws.com": ["StopLogging", "DeleteTrail", "UpdateTrail"],
}
def rule(event):
# Check if this is a VPC Endpoint network activity event
if event.get("eventType") != "AwsVpceEvent" or event.get("eventCategory") != "NetworkActivity":
return False
event_source = event.get("eventSource")
event_name = event.get("eventName")
if event_source in SENSITIVE_APIS and event_name in SENSITIVE_APIS[event_source]:
return True
return False
def title(event):
# Use UDM actor_user which leverages the get_actor_user helper function
# This properly handles various identity types including AssumedRole, Root, etc.
actor_user = event.udm("actor_user")
api_name = event.get("eventName", "unknown")
service = event.get("eventSource", "unknown").split(".")[0]
return (
f"Sensitive AWS API [{api_name}] called via VPC Endpoint by [{actor_user}] "
f"to service [{service}]"
)
def alert_context(event):
account_id = event.deep_get("userIdentity", "accountId", default="")
context = aws_rule_context(event)
context.update(
{
"account_id": account_id,
"principal_id": event.deep_get("userIdentity", "principalId", default="unknown"),
"principal_type": event.deep_get("userIdentity", "type", default="unknown"),
"actor_user": event.udm("actor_user"),
"source_ip": event.get("sourceIPAddress", "unknown"),
"event_source": event.get("eventSource", "unknown"),
"api_call": event.get("eventName", "unknown"),
"resources": event.get("resources", []),
"request_parameters": event.get("requestParameters", {}),
}
)
return context
Rule specification
AnalysisType: rule
Filename: aws_vpce_sensitive_api_calls.py
RuleID: "AWS.CloudTrail.VPCE.SensitiveAPICalls"
DisplayName: "Sensitive API Calls Via VPC Endpoint"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Medium
Tags:
- AWS
- VPC
- CloudTrail
- Network Boundary Bridging
- Cloud Service Discovery
- Account Manipulation
- Impair Defenses
Reports:
MITRE ATT&CK:
- TA0007:T1526 # Cloud Service Discovery
- TA0003:T1098 # Account Manipulation
- TA0005:T1562 # Impair Defenses
- TA0005:T1599 # Network Boundary Bridging
Description: Detects sensitive or unusual API calls that might indicate lateral movement, reconnaissance, or other malicious activities through VPC Endpoints. Only available for CloudTrail, EC2, KMS, S3, and Secrets Manager services.
Runbook: |
1. Identify the principal making the sensitive API call and the specific service affected
2. Determine if this action is expected from this principal
3. Check if the API call is one that typically requires additional scrutiny (e.g., logging configuration changes)
4. Investigate whether the VPC Endpoint is configured to properly restrict access
5. Review additional API calls from the same principal for suspicious patterns
6. If unexpected activity is confirmed, consider temporarily restricting the principal's access
7. Document findings and take appropriate remediation steps based on investigation
Reference: https://www.wiz.io/blog/aws-vpc-endpoint-cloudtrail
SummaryAttributes:
- userIdentity.principalId
- userIdentity.accountId
- sourceIPAddress
- eventSource
- eventName
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventTypeisAwsVpceEventeventCategoryisNetworkActivityeventSourceis one ofec2.amazonaws.com,kms.amazonaws.com,secretsmanager.amazonaws.com,s3.amazonaws.com,cloudtrail.amazonaws.com
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
eventCategory | ne | NetworkActivity | excludes:eventCategory field:"eventCategory" value:"NetworkActivity" |
eventType | ne | AwsVpceEvent | excludes:eventType field:"eventType" value:"AwsVpceEvent" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventSource | in |
| field:"aws::eventSource" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
1. Identify the principal making the sensitive API call and the specific service affected
2. Determine if this action is expected from this principal
3. Check if the API call is one that typically requires additional scrutiny (e.g., logging configuration changes)
4. Investigate whether the VPC Endpoint is configured to properly restrict access
5. Review additional API calls from the same principal for suspicious patterns
6. If unexpected activity is confirmed, consider temporarily restricting the principal's access
7. Document findings and take appropriate remediation steps based on investigation
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "NetworkActivity",
"eventName": "UpdateTrail",
"eventSource": "cloudtrail.amazonaws.com",
"eventTime": "2023-03-01T00:00:00Z",
"eventType": "AwsVpceEvent",
"eventVersion": "1.08",
"recipientAccountId": "111111111111",
"requestParameters": {
"isMultiRegionTrail": false,
"name": "management-events"
},
"responseElements": null,
"sourceIPAddress": "10.0.0.1",
"userIdentity": {
"accountId": "111111111111",
"principalId": "AROAEXAMPLE:session-name",
"type": "IAMUser"
},
"vpcEndpointAccountId": "111111111111",
"vpcEndpointId": "vpce-1234abcd"
}
Sensitive AWS CloudWatch Log Encryption
#AWS automatically performs server-side encryption of logs, but you can encrypt with your own CMK to protect extra sensitive log data.
Detection logic
from fnmatch import fnmatch
# replace the log groups ARN regex with the
# log groups that should be encrypted with your CMK
SENSITIVE_LOG_GROUP_ARN_REGEXS = {"*LogGroup-2*"}
def policy(resource):
if resource["KmsKeyId"] is None:
if SENSITIVE_LOG_GROUP_ARN_REGEXS and any(
fnmatch(resource.get("Arn"), group_arn) for group_arn in SENSITIVE_LOG_GROUP_ARN_REGEXS
):
return False
return True
Rule specification
AnalysisType: policy
Filename: aws_cloudwatch_loggroup_sensitive_encrypted.py
PolicyID: "AWS.CloudWatchLogs.SensitiveLogGroup.Encryption"
DisplayName: "Sensitive AWS CloudWatch Log Encryption"
Enabled: false
ResourceTypes:
- AWS.CloudWatch.LogGroup
Tags:
- AWS
- Configuration Required
- Panther
Severity: High
Description: >
AWS automatically performs server-side encryption of logs, but you can encrypt with your own CMK
to protect extra sensitive log data.
Runbook: >
Encrypt the CloudWatch log group with a KMS key, or add this log group to the ignore list (SENSITIVE_LOG_GROUP_ARN_REGEXS).
Reference: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/encrypt-log-data-kms.html
Stages and Predicates
Rule logic imperative Python
The parser could not express this rule's Python logic as a structured condition; the complete logic is under Detection logic above.
Response runbook
Encrypt the CloudWatch log group with a KMS key, or add this log group to the ignore list (SENSITIVE_LOG_GROUP_ARN_REGEXS).
SIGNAL - AWS Console SSO Sign-In
#Telemetry coverage
Detection logic
def rule(event):
return (
event.get("eventSource") == "sso.amazonaws.com" and event.get("eventName") == "Authenticate"
)
Rule specification
AnalysisType: rule
Filename: aws_console_signin.py
RuleID: "AWS.Console.Sign-In"
DisplayName: "SIGNAL - AWS Console SSO Sign-In"
Enabled: true
CreateAlert: false
LogTypes:
- AWS.CloudTrail
Severity: Info
DedupPeriodMinutes: 60
Threshold: 1
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventSourceissso.amazonaws.comeventNameisAuthenticate
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
eventName | eq |
| field:"aws::eventName" kind:eq value:"Authenticate" |
eventSource | eq |
| field:"aws::eventSource" kind:eq value:"sso.amazonaws.com" |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"eventCategory": "Management",
"eventID": "8cb05708-9764-4774-a048-59a4c8e1684d",
"eventName": "Authenticate",
"eventSource": "sso.amazonaws.com",
"eventTime": "2024-06-03 15:23:22.000000000",
"eventType": "AwsServiceEvent",
"eventVersion": "1.08",
"managementEvent": true
}
Signal - VPC Flow Logs Allowed SSH
#VPC Flow Logs observed inbound traffic on SSH port. This rule is a signal to be used in correlation rules.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Lateral Movement |
Detection logic
from ipaddress import ip_network
from panther_aws_helpers import aws_rule_context
def rule(event):
# Defaults to True (no alert) if 'dstport' is not present
if event.udm("destination_port") != 22 or event.get("action") != "ACCEPT":
return False
# Only monitor for traffic coming from non-private IP space
#
# Defaults to True (no alert) if 'srcaddr' key is not present
source_ip = event.udm("source_ip") or "0.0.0.0/32"
if not ip_network(source_ip).is_global:
return False
# Alert if the traffic is destined for internal IP addresses
#
# Defaults to False(no alert) if 'dstaddr' key is not present
destination_ip = event.udm("destination_ip") or "1.0.0.0/32"
return not ip_network(destination_ip).is_global
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_vpc_ssh_allowed_signal.py
RuleID: "AWS.VPC.SSHAllowedSignal"
DisplayName: "Signal - VPC Flow Logs Allowed SSH"
Enabled: true
CreateAlert: false
LogTypes:
- AWS.VPCFlow
Tags:
- AWS
- Signal
Reports:
MITRE ATT&CK:
- TA0008:T1021.004 # Lateral Movement: Remote Services: SSH
Severity: Info
Description: >
VPC Flow Logs observed inbound traffic on SSH port.
This rule is a signal to be used in correlation rules.
Stages and Predicates
Fires on AWS.VPCFlow events when all of the conditions below hold.
Condition
destination_portis22actionisACCEPT
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
action | ne | ACCEPT | excludes:action field:"action" value:"ACCEPT" |
destination_port | ne | 22 | excludes:destination_port field:"destination_port" value:"22" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"action": "ACCEPT",
"dstAddr": "10.0.0.1",
"dstPort": 22,
"instanceId": "i-0d4c7318592c6a2c7",
"p_log_type": "AWS.VPCFlow",
"srcAddr": "1.1.1.1"
}
StopInstance WITH ModifyInstanceAttributes
#Identifies when StopInstance and ModifyInstanceAttributes CloudTrail events occur in a short period of time. Since EC2 startup scripts cannot be modified without first stopping the instance, StopInstances should be a signal.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Execution |
Rule specification
AnalysisType: correlation_rule
RuleID: "AWS.EC2.StopInstance.WITH.ModifyInstanceAttributes"
DisplayName: "StopInstance WITH ModifyInstanceAttributes"
Enabled: false
Severity: High
Description: Identifies when StopInstance and ModifyInstanceAttributes CloudTrail events occur in a short period of time. Since EC2 startup scripts cannot be modified without first stopping the instance, StopInstances should be a signal.
Reference: https://unit42.paloaltonetworks.com/malicious-operations-of-exposed-iam-keys-cryptojacking/
Reports:
MITRE ATT&CK:
- TA0002:T1059
Detection:
- Group:
- ID: StopInstance
RuleID: AWS.EC2.StopInstances
- ID: StartupScriptChange
RuleID: AWS.EC2.Startup.Script.Change
MatchCriteria:
field_name:
- GroupID: StopInstance
Match: p_alert_context.instance_ids
- GroupID: StartupScriptChange
Match: p_alert_context.instance_ids
LookbackWindowMinutes: 1800
Schedule:
RateMinutes: 1440
TimeoutMinutes: 5
Stages and Predicates
Fires when the steps below all occur within 30h, correlated by p_alert_context.instance_ids. Each step needs one match unless a higher minimum is shown.
Stage 1: step StopInstance
References detection CloudTrail EC2 StopInstances.
Stage 2: step StartupScriptChange
References detection AWS EC2 Startup Script Change.
Unused AWS Region
#CloudTrail logged non-read activity from a verboten AWS region.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Stealth |
Detection logic
from panther_aws_helpers import aws_rule_context
# Define a list of verboten or unused regions
# Could modify to include expected user mappings: { "123456789012": { "us-west-1", "us-east-2" } }
UNUSED_REGIONS = {"ap-east-1", "eu-west-3", "eu-central-1"}
def rule(event):
if (
event.get("awsRegion", "<UNKNOWN_AWS_REGION>") in UNUSED_REGIONS
and event.get("readOnly") is False
):
return True
return False
def title(event):
aws_username = event.deep_get("userIdentity", "sessionContext", "sessionIssuer", "userName")
return (
"Non-read-only API call in unused region"
f" {event.get('awsRegion', '<UNKNOWN_AWS_REGION>')} by user {aws_username}"
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_unused_region.py
RuleID: "AWS.UnusedRegion"
DisplayName: "Unused AWS Region"
Enabled: false
LogTypes:
- AWS.CloudTrail
Tags:
- AWS
- Defense Evasion:Unused/Unsupported Cloud Regions
- Configuration Required
Reports:
MITRE ATT&CK:
- TA0005:T1535
Severity: High
Description: CloudTrail logged non-read activity from a verboten AWS region.
Runbook: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws-enable-disable-regions.html
Reference: https://attack.mitre.org/techniques/T1535/
SummaryAttributes:
- eventSource
- eventName
- recipientAccountId
- awsRegion
- p_any_aws_arns
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
awsRegionis one ofap-east-1,eu-west-3,eu-central-1readOnlyisfalse
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
awsRegion | in |
| field:"awsRegion" kind:in |
readOnly | eq |
| field:"readOnly" kind:eq value:"false" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field | Source |
|---|---|
eventName | |
eventSource | |
awsRegion | |
recipientAccountId | |
sourceIPAddress | |
userAgent | |
userIdentity | |
userName | userIdentity.sessionContext.sessionIssuer.userName |
Response runbook
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"apiVersion": "20140328",
"awsRegion": "eu-central-1",
"eventID": "1111",
"eventName": "CreateLogStream",
"eventSource": "logs.amazonaws.com",
"eventTime": "2021-10-21 22:29:06",
"eventType": "AwsApiCall",
"eventVersion": "1.08",
"managementEvent": true,
"readOnly": false,
"recipientAccountId": "123456789012",
"requestID": "1111",
"requestParameters": {
"logGroupName": "/aws/lambda/panther-analysis-api",
"logStreamName": "2021/10/21/[$LATEST]1111"
},
"sourceIPAddress": "111.111.111.111",
"userAgent": "awslambda-worker/1.0 rusoto/0.47.0 rust/1.55.0 linux",
"userIdentity": {
"accessKeyId": "1111",
"accountId": "123456789012",
"arn": "arn:aws:sts::123456789012:assumed-role/example-role/example-user",
"principalId": "1111",
"sessionContext": {
"attributes": {
"creationDate": "2021-10-21T22:29:02Z",
"mfaAuthenticated": "false"
},
"sessionIssuer": {
"accountId": "123456789012",
"arn": "arn:aws:iam::123456789012:role/example-role",
"principalId": "1111",
"type": "Role",
"userName": "example-role"
},
"webIdFederationData": {}
},
"type": "AssumedRole"
}
}
VPC Endpoint Access Denied
#Detects when access is denied due to VPC Endpoint policies, which could indicate attempted unauthorized access to AWS resources.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Defense Impairment | |
| Discovery |
Detection logic
from panther_aws_helpers import aws_rule_context
def rule(event):
# Check if this is a VPC Endpoint network activity event
if event.get("eventType") != "AwsVpceEvent" or event.get("eventCategory") != "NetworkActivity":
return False
# Look for access denied errors
if event.get("errorCode") == "VpceAccessDenied":
return True
return False
def title(event):
actor_user = event.udm("actor_user")
source_ip = event.get("sourceIPAddress", "unknown")
service = event.get("eventSource", "unknown").split(".")[0]
return f"VPC Endpoint Access Denied for [{actor_user}] from [{source_ip}] to [{service}]"
def alert_context(event):
account_id = event.deep_get("userIdentity", "accountId", default="unknown")
context = aws_rule_context(event)
context.update(
{
"account_id": account_id,
"principal_id": event.deep_get("userIdentity", "principalId", default="unknown"),
"source_ip": event.get("sourceIPAddress", "unknown"),
"event_source": event.get("eventSource", "unknown"),
"api_call": event.get("eventName", "unknown"),
"error_message": event.get("errorMessage", ""),
"resources": event.get("resources", []),
"actor_user": event.udm("actor_user"),
}
)
return context
Rule specification
AnalysisType: rule
Filename: aws_vpce_access_denied.py
RuleID: "AWS.CloudTrail.VPCE.AccessDenied"
DisplayName: "VPC Endpoint Access Denied"
Enabled: true
LogTypes:
- AWS.CloudTrail
Severity: Medium
Tags:
- AWS
- VPC
- CloudTrail
- Network Boundary Bridging
- Defense Evasion
- Lateral Movement
- Impair Defenses
Reports:
MITRE ATT&CK:
- TA0005:T1599 # Network Boundary Bridging
- TA0007:T1526 # Cloud Service Discovery
Description: Detects when access is denied due to VPC Endpoint policies, which could indicate attempted unauthorized access to AWS resources.
Runbook: |
1. Identify the principal (user/role) and source IP that was denied access
2. Determine if this is expected behavior based on your VPC endpoint policies
3. Check if there are multiple failed attempts from the same principal/IP
4. If unexpected, investigate why the principal is attempting to access resources through the VPC endpoint
5. Consider updating your VPC endpoint policies if necessary
6. Document findings and take appropriate remediation steps based on investigation
Reference: https://www.wiz.io/blog/aws-vpc-endpoint-cloudtrail
SummaryAttributes:
- errorCode
- errorMessage
- sourceIPAddress
- eventSource
- eventName
- userIdentity.principalId
Stages and Predicates
Fires on AWS.CloudTrail events when all of the conditions below hold.
Condition
eventTypeisAwsVpceEventeventCategoryisNetworkActivityerrorCodeisVpceAccessDenied
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
eventCategory | ne | NetworkActivity | excludes:eventCategory field:"eventCategory" value:"NetworkActivity" |
eventType | ne | AwsVpceEvent | excludes:eventType field:"eventType" value:"AwsVpceEvent" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
errorCode | eq |
| field:"aws::errorCode" kind:eq value:"VpceAccessDenied" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
actor_user |
Response runbook
1. Identify the principal (user/role) and source IP that was denied access
2. Determine if this is expected behavior based on your VPC endpoint policies
3. Check if there are multiple failed attempts from the same principal/IP
4. If unexpected, investigate why the principal is attempting to access resources through the VPC endpoint
5. Consider updating your VPC endpoint policies if necessary
6. Document findings and take appropriate remediation steps based on investigation
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"awsRegion": "us-east-1",
"errorCode": "VpceAccessDenied",
"errorMessage": "The request was denied due to a VPC endpoint policy",
"eventCategory": "NetworkActivity",
"eventName": "GetObject",
"eventSource": "s3.amazonaws.com",
"eventTime": "2023-03-01T00:00:00Z",
"eventType": "AwsVpceEvent",
"eventVersion": "1.08",
"recipientAccountId": "222222222222",
"requestParameters": {
"bucketName": "example-bucket",
"key": "sensitive-file.txt"
},
"responseElements": null,
"sourceIPAddress": "10.0.0.1",
"userIdentity": {
"accountId": "111111111111",
"principalId": "AROAEXAMPLE:session-name",
"type": "AWSAccount"
},
"vpcEndpointAccountId": "222222222222",
"vpcEndpointId": "vpce-EXAMPLE08c1b6b9b7"
}
VPC Flow Logs Inbound Port Allowlist
#VPC Flow Logs observed inbound traffic violating the port allowlist.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Command & Control |
Detection logic
from ipaddress import ip_network
from panther_aws_helpers import aws_rule_context
APPROVED_PORTS = {
80,
443,
}
def rule(event):
# Can't perform this check without a destination port
if not event.udm("destination_port"):
return False
# Only monitor for non allowlisted ports
if event.udm("destination_port") in APPROVED_PORTS:
return False
# Only monitor for traffic coming from non-private IP space
#
# Defaults to True (no alert) if 'srcaddr' key is not present
source_ip = event.udm("source_ip") or "0.0.0.0/32"
if not ip_network(source_ip).is_global:
return False
# Alert if the traffic is destined for internal IP addresses
#
# Defaults to False (no alert) if 'dstaddr' key is not present
destination_ip = event.udm("destination_ip") or "1.0.0.0/32"
return not ip_network(destination_ip).is_global
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_vpc_inbound_traffic_port_allowlist.py
RuleID: "AWS.VPC.InboundPortWhitelist"
DisplayName: "VPC Flow Logs Inbound Port Allowlist"
Enabled: false
LogTypes:
- AWS.VPCFlow
- OCSF.NetworkActivity
Tags:
- AWS
- DataModel
- Configuration Required
- Security Control
- Command and Control:Non-Standard Port
Reports:
MITRE ATT&CK:
- TA0011:T1571
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html
Severity: High
Description: >
VPC Flow Logs observed inbound traffic violating the port allowlist.
Runbook: >
Block the unapproved traffic, or update the approved ports list.
SummaryAttributes:
- srcaddr
- dstaddr
- dstport
Stages and Predicates
Fires on AWS.VPCFlow, OCSF.NetworkActivity events when all of the conditions below hold.
Condition
destination_portis presentdestination_portis not one of80,443
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
destination_port | in | 443, 80 | excludes:destination_port field:"destination_port" value:"443" field:"destination_port" value:"80" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
destination_port | is_not_null | field:"destination_port" kind:is_not_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Block the unapproved traffic, or update the approved ports list.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"dstAddr": "10.0.0.1",
"dstPort": 22,
"p_log_type": "AWS.VPCFlow",
"srcAddr": "1.1.1.1"
}
VPC Flow Logs Inbound Port Blocklist
#VPC Flow Logs observed inbound traffic violating the port blocklist.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Command & Control |
Detection logic
from ipaddress import ip_network
from panther_aws_helpers import aws_rule_context
CONTROLLED_PORTS = {
22,
3389,
}
def rule(event):
# Only monitor for blocklisted ports
#
# Defaults to True (no alert) if 'dstport' is not present
if event.udm("destination_port") not in CONTROLLED_PORTS:
return False
# Only monitor for traffic coming from non-private IP space
#
# Defaults to True (no alert) if 'srcaddr' key is not present
source_ip = event.udm("source_ip") or "0.0.0.0/32"
if not ip_network(source_ip).is_global:
return False
# Alert if the traffic is destined for internal IP addresses
#
# Defaults to False(no alert) if 'dstaddr' key is not present
destination_ip = event.udm("destination_ip") or "1.0.0.0/32"
return not ip_network(destination_ip).is_global
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_vpc_inbound_traffic_port_blocklist.py
RuleID: "AWS.VPC.InboundPortBlacklist"
DisplayName: "VPC Flow Logs Inbound Port Blocklist"
Enabled: false
LogTypes:
- AWS.VPCFlow
- OCSF.NetworkActivity
Tags:
- AWS
- DataModel
- Configuration Required
- Security Control
- Command and Control:Non-Standard Port
Reports:
MITRE ATT&CK:
- TA0011:T1571
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html
Severity: High
Description: >
VPC Flow Logs observed inbound traffic violating the port blocklist.
Runbook: >
Block the unapproved traffic, or update the unapproved ports list.
SummaryAttributes:
- srcaddr
- dstaddr
- dstport
Stages and Predicates
Fires on AWS.VPCFlow, OCSF.NetworkActivity events when the condition below holds.
Condition
destination_portis one of22,3389
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
destination_port | in |
| field:"destination_port" kind:in |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Block the unapproved traffic, or update the unapproved ports list.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"dstAddr": "10.0.0.1",
"dstPort": 22,
"p_log_type": "AWS.VPCFlow",
"srcAddr": "1.1.1.1"
}
VPC Flow Logs Unapproved Outbound DNS Traffic
#Alerts if outbound DNS traffic is detected to a non-approved DNS server. DNS is often used as a means to exfiltrate data or perform command and control for compromised hosts. All DNS traffic should be routed through internal DNS servers or trusted 3rd parties.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Command & Control |
Detection logic
from ipaddress import ip_network
from panther_aws_helpers import aws_rule_context
APPROVED_DNS_SERVERS = {
"1.1.1.1", # CloudFlare DNS
"8.8.8.8", # Google DNS
# '10.0.0.1', # Internal DNS
}
def rule(event):
# Common DNS ports, for better security use an application layer aware network monitor
#
# Defaults to True (no alert) if 'dstport' key is not present
if event.udm("destination_port") != 53 and event.udm("destination_port") != 5353:
return False
# Only monitor traffic that is originating internally
#
# Defaults to True (no alert) if 'srcaddr' key is not present
source_ip = event.udm("source_ip") or "0.0.0.0/32"
if ip_network(source_ip).is_global:
return False
dest_ip = event.udm("destination_ip") or "192.168.0.1/32"
if ip_network(dest_ip).is_private:
return False
# No clean way to default to False (no alert), so explicitly check for key
return (
bool(event.udm("destination_ip"))
and event.udm("destination_ip") not in APPROVED_DNS_SERVERS
)
def alert_context(event):
return aws_rule_context(event)
Rule specification
AnalysisType: rule
Filename: aws_vpc_unapproved_outbound_dns.py
RuleID: "AWS.VPC.UnapprovedOutboundDNS"
DisplayName: "VPC Flow Logs Unapproved Outbound DNS Traffic"
Enabled: false
LogTypes:
- AWS.VPCFlow
- OCSF.NetworkActivity
Tags:
- AWS
- DataModel
- Configuration Required
- Security Control
- Command and Control:Application Layer Protocol
Reports:
MITRE ATT&CK:
- TA0011:T1071
Reference: https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html
Severity: Medium
Description: >
Alerts if outbound DNS traffic is detected to a non-approved DNS server. DNS is often used as a means to exfiltrate data or perform command and control for compromised hosts. All DNS traffic should be routed through internal DNS servers or trusted 3rd parties.
Runbook: >
Investigate the host sending unapproved DNS activity for signs of compromise or other malicious activity. Update network configurations appropriately to ensure all DNS traffic is routed to approved DNS servers.
SummaryAttributes:
- srcaddr
- dstaddr
- dstport
Stages and Predicates
Fires on AWS.VPCFlow, OCSF.NetworkActivity events when all of the conditions below hold.
Condition
any of:
destination_portis53destination_portis5353
destination_ipis presentdestination_ipis not one of1.1.1.1,8.8.8.8
This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
destination_port | ne | 53 | excludes:destination_port field:"destination_port" value:"53" |
destination_port | ne | 5353 | excludes:destination_port field:"destination_port" value:"5353" |
destination_ip | in | 1.1.1.1, 8.8.8.8 | excludes:destination_ip field:"destination_ip" value:"1.1.1.1" field:"destination_ip" value:"8.8.8.8" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
destination_ip | is_not_null | field:"destination_ip" kind:is_not_null |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
eventName |
eventSource |
awsRegion |
recipientAccountId |
sourceIPAddress |
userAgent |
userIdentity |
Response runbook
Investigate the host sending unapproved DNS activity for signs of compromise or other malicious activity. Update network configurations appropriately to ensure all DNS traffic is routed to approved DNS servers.
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"dstAddr": "100.100.100.100",
"dstPort": 53,
"p_log_type": "AWS.VPCFlow",
"srcAddr": "10.0.0.1"
}
VPC Flow Port Scanning
#Detects potential port scanning activity by alerting when a single source address communicates with 10 or more distinct destination ports on the same target within 60 minutes. Common ports (80, 443, 53, etc.) are excluded to reduce noise.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery |
Detection logic
from ipaddress import ip_address
COMMON_PORTS = {80, 123, 443, 445, 53, 853, 2049}
def rule(event):
if event.get("flowDirection") != "egress":
return False
src_addr = event.get("srcAddr", "")
if not src_addr or src_addr == "null":
return False
if event.get("dstPort") in COMMON_PORTS:
return False
return True
def title(event):
src = event.get("srcAddr", "Unknown")
dst = event.get("dstAddr", "Unknown")
return f"Port Scanning Detected from [{src}] to [{dst}]"
def dedup(event):
src = event.get("srcAddr", "")
dst = event.get("dstAddr", "")
vpc = event.get("vpcId", "")
region = event.get("region", "")
subnet = event.get("subNetId", "")
return f"{src}:{dst}:{vpc}:{region}:{subnet}"
def unique(event):
port = event.get("dstPort")
return str(port) if port is not None else None
def severity(event):
try:
src = ip_address(event.get("srcAddr", ""))
if src.is_private:
return "HIGH"
except ValueError:
pass
return "DEFAULT"
Rule specification
AnalysisType: rule
Filename: aws_vpc_port_scanning.py
RuleID: "AWS.VPC.PortScanning"
DisplayName: "VPC Flow Port Scanning"
Status: Experimental
Enabled: false
Severity: Medium
DedupPeriodMinutes: 60
Threshold: 10
LogTypes:
- AWS.VPCFlow
Description: >
Detects potential port scanning activity by alerting when a single source address
communicates with 10 or more distinct destination ports on the same target within 60 minutes.
Common ports (80, 443, 53, etc.) are excluded to reduce noise.
Reports:
MITRE ATT&CK:
- TA0007:T1046
Tags:
- Discovery:Network Service Discovery
Runbook: |
1. Query VPC Flow logs for all egress traffic from srcAddr in the 1 hour around this alert to identify the full sequence of dstPort values targeted on dstAddr within vpcId
2. Check if srcAddr is associated with known vulnerability scanners, corporate IT tooling, or threat intelligence feeds
3. Find other alerts involving srcAddr or vpcId in the past 7 days to determine if this is part of ongoing reconnaissance
Stages and Predicates
Fires on AWS.VPCFlow events when all of the conditions below hold.
Condition
flowDirectionisegresssrcAddris presentsrcAddris notnulldstPortis not one of80,123,443,445,53
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
srcAddr | eq | null | excludes:srcAddr field:"srcAddr" value:"null" |
srcAddr | is_null | excludes:srcAddr | |
dstPort | in | 123, 2049, 443, 445, 53, 80, 853 | excludes:dstPort |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
flowDirection | eq |
| field:"flowDirection" kind:eq value:"egress" |
Output fields
Fields the rule emits when it matches, drawn from the rule's alert_context.
| Field |
|---|
srcAddr |
dstAddr |
Response runbook
1. Query VPC Flow logs for all egress traffic from srcAddr in the 1 hour around this alert to identify the full sequence of dstPort values targeted on dstAddr within vpcId
2. Check if srcAddr is associated with known vulnerability scanners, corporate IT tooling, or threat intelligence feeds
3. Find other alerts involving srcAddr or vpcId in the past 7 days to determine if this is part of ongoing reconnaissance
Worked example
A sample event from the rule's unit tests that triggers a match.Sample Test Event
{
"dstAddr": "192.168.1.1",
"dstPort": 8080,
"flowDirection": "egress",
"region": "us-east-1",
"srcAddr": "10.0.0.1",
"srcPort": 54321,
"subNetId": "subnet-12345678",
"vpcId": "vpc-12345678"
}