In 04-02 we encrypted the mercadofresco-pedidos backups with the alias/mercadofresco-datos key,
and said that KMS scrupulously records every cryptographic operation. In 04-03 we stored the
mfadmin password in the mercadofresco/produccion/rds/mfadmin secret and said that every read is
noted down. In 04-01 we created the rol-mercadofresco-tienda role and said that every AssumeRole
leaves a trace.
None of those three things is a lie. All of them are recorded. And nobody has ever looked at them.
That record is called AWS CloudTrail, and it answers a question that neither CloudWatch nor X-Ray
can answer. CloudWatch knows what your application says about itself. X-Ray knows where a customer
request went. CloudTrail knows who called the AWS API, when, from where, with what parameters and
with what result. It is the difference between knowing the shop is slow and knowing that somebody,
at 03:42 on a Tuesday, from an IP in a country where you have no offices, called kms:Decrypt on
the database backup.
This lesson sets up the trail-mercadofresco trail, learns to read a real event, and finally answers
the third question module 4 left open.
Compliance warning. Audit logs usually carry legal retention requirements. Depending on the sector and the country, they can range from 6 months to 10 years, and in some cases they must be tamper-proof and held in separate custody. This material is educational: the decisions on retention, immutability and access to the audit logs of a real system must be validated by a compliance professional or your organisation's legal adviser. Do not take them yourself on the basis of a course.
Contents
- What CloudTrail records and what it does not
- CloudTrail versus CloudWatch Logs
- The free 90-day event history
- Creating the
trail-mercadofrescotrail - The bucket nobody must be able to delete
- Log file integrity validation
- Anatomy of an event: the annotated JSON
userIdentity: the six types and how to read them- Who decrypted the last backup
- Management events versus data events
- Insights events
- Multi-region and organisation trails
- Sending the trail to CloudWatch Logs
- Metric filters and security alarms
- Querying with Athena
- The queries that answer real questions
- CloudTrail Lake
- Investigating an incident, step by step
- Relationship with IAM Access Analyzer
- Retention, cost and cleanup
What CloudTrail records and what it does not
CloudTrail records calls to the AWS API. All of them. It does not matter whether they are made by a person in the console, the CLI, an SDK, an AWS service acting on your behalf or a Lambda function: underneath, everything is signed HTTPS calls to the AWS APIs, and CloudTrail sees them all.
What it does not record:
| Not recorded | Example | Where it is |
|---|---|---|
| What your application does internally | pedido 48213 confirmado |
CloudWatch Logs (05-01) |
| Your customers' HTTP traffic | GET /buscar?q=tomate |
ALB / CloudFront logs |
| SQL queries against your database | SELECT * FROM pedidos |
RDS logs |
| Network traffic between instances | TCP packets | VPC Flow Logs (03-01) |
| Requests for S3 objects | GET productos/tomate.jpg |
Data events (must be enabled) |
| The contents of a secret | The password value | Never recorded |
That last row matters: CloudTrail records that somebody called GetSecretValue on
mercadofresco/produccion/rds/mfadmin, but it does not record the password. Sensitive fields are
omitted or obfuscated by design. The same with kms:Decrypt: it records the call and the key
identifier, not the plaintext.
And a structural property worth understanding: CloudTrail is always on. It is not something you "enable": the last 90 days of management events are available free in your account from day one, even if you have never created a trail. What you enable is persistence.
CloudTrail versus CloudWatch Logs
It is the number one confusion of this module, and it deserves a table:
| CloudTrail | CloudWatch Logs | |
|---|---|---|
| Records | Calls to the AWS API | What your application writes |
| Source of the data | The AWS control plane | Your code, the agent, the services |
| Question it answers | Who did what? | What happened inside? |
| Example | mercadofresco-admin deleted the bucket |
ERROR pago rechazado pedido 48213 |
| Scope | Account (and organisation) | Region, log group |
| On by default | Yes, 90 days free | Only if you publish |
| Format | Structured, fixed JSON | Whatever you write |
| Retention | 90 days / as long as the bucket lasts | Whatever you configure |
| Tampering with it | Very hard (with the protections) | Easy, if you have permissions |
| Main use | Audit, forensics, compliance | Operations, debugging |
A concrete case to pin it down. Somebody deletes the mercadofresco-registros-web bucket:
- CloudWatch Logs: nothing. Your application has written nothing, because it was not your application.
- CloudTrail: a
DeleteBucketevent, with the identity that did it, the exact time, the source IP, the user agent (aws-cli/2.15.0orconsole.amazonaws.com), and whether it was done with MFA.
And the reverse case. The shop returns 500 when confirming an order:
- CloudTrail: nothing. Nobody called any AWS API in an anomalous way.
- CloudWatch Logs: the full trace of the error.
They do not compete: they cover different universes.
The free 90-day event history
Before creating anything, there is something that already works:
# The latest write events in the region
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=ReadOnly,AttributeValue=false \
--max-results 20 \
--query 'Events[].[EventTime,Username,EventName,EventSource]' \
--output table \
--profile mercadofresco-dev --region eu-west-1
# Everything a specific user has done
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=mercadofresco-admin \
--start-time 2026-07-01T00:00:00Z \
--profile mercadofresco-dev --region eu-west-1
# Every call to a specific event
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=Decrypt \
--profile mercadofresco-dev --region eu-west-1The available search attributes are limited and you need to know them, because they are what you can
do without infrastructure: EventId, EventName, EventSource, ReadOnly, ResourceName,
ResourceType, Username, AccessKeyId.
The five limitations of the free history, which are exactly the reasons for creating a trail:
- Only 90 days. An investigation into an incident detected late runs out of data.
- Management events only. There are no data events (accesses to S3 objects, Lambda invocations).
- One search attribute per query. You cannot cross "this user" and "this action".
- Not exportable and not queryable with SQL. No serious analysis is possible.
- Neither immutable nor verifiable. It is no use as evidence in a formal audit.
Creating the trail-mercadofresco trail
A trail persists the events in an S3 bucket, indefinitely, and enables everything above.
Step 1: the dedicated bucket.
aws s3api create-bucket \
--bucket mercadofresco-auditoria-cloudtrail \
--region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1 \
--profile mercadofresco-dev
# Full public access block (02-03)
aws s3api put-public-access-block \
--bucket mercadofresco-auditoria-cloudtrail \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" \
--profile mercadofresco-dev
# Versioning: essential for Object Lock and for recovering deletions
aws s3api put-bucket-versioning \
--bucket mercadofresco-auditoria-cloudtrail \
--versioning-configuration Status=Enabled \
--profile mercadofresco-dev
# Encryption with the key managed by MercadoFresco (04-02)
aws s3api put-bucket-encryption \
--bucket mercadofresco-auditoria-cloudtrail \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:eu-west-1:111122223333:alias/mercadofresco-datos"
},
"BucketKeyEnabled": true
}]
}' \
--profile mercadofresco-devThat BucketKeyEnabled: true is not cosmetic: it cuts KMS calls —and their cost— by up to 99 % when
thousands of small objects are written, which is exactly what CloudTrail does. We saw it in
04-02.
Step 2: the bucket policy. CloudTrail needs permission to write:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CloudTrailComprobarAcl",
"Effect": "Allow",
"Principal": { "Service": "cloudtrail.amazonaws.com" },
"Action": "s3:GetBucketAcl",
"Resource": "arn:aws:s3:::mercadofresco-auditoria-cloudtrail",
"Condition": {
"StringEquals": {
"aws:SourceArn": "arn:aws:cloudtrail:eu-west-1:111122223333:trail/trail-mercadofresco"
}
}
},
{
"Sid": "CloudTrailEscribir",
"Effect": "Allow",
"Principal": { "Service": "cloudtrail.amazonaws.com" },
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::mercadofresco-auditoria-cloudtrail/AWSLogs/111122223333/*",
"Condition": {
"StringEquals": {
"s3:x-amz-acl": "bucket-owner-full-control",
"aws:SourceArn": "arn:aws:cloudtrail:eu-west-1:111122223333:trail/trail-mercadofresco"
}
}
},
{
"Sid": "DenegarBorradoATodoElMundo",
"Effect": "Deny",
"Principal": "*",
"Action": [
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:PutBucketPolicy",
"s3:DeleteBucketPolicy",
"s3:PutLifecycleConfiguration"
],
"Resource": [
"arn:aws:s3:::mercadofresco-auditoria-cloudtrail",
"arn:aws:s3:::mercadofresco-auditoria-cloudtrail/*"
],
"Condition": {
"ArnNotEquals": {
"aws:PrincipalArn": "arn:aws:iam::111122223333:role/rol-custodia-auditoria"
}
}
},
{
"Sid": "DenegarTrafficoSinTLS",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::mercadofresco-auditoria-cloudtrail",
"arn:aws:s3:::mercadofresco-auditoria-cloudtrail/*"
],
"Condition": { "Bool": { "aws:SecureTransport": "false" } }
}
]
}The aws:SourceArn conditions are important and are not always added: without them, in theory a trail
from another account could write into your bucket (the "confused deputy" problem we saw in 04-01).
Step 3: the trail.
aws cloudtrail create-trail \
--name trail-mercadofresco \
--s3-bucket-name mercadofresco-auditoria-cloudtrail \
--is-multi-region-trail \
--include-global-service-events \
--enable-log-file-validation \
--kms-key-id arn:aws:kms:eu-west-1:111122223333:alias/mercadofresco-datos \
--cloud-watch-logs-log-group-arn arn:aws:logs:eu-west-1:111122223333:log-group:/aws/cloudtrail/mercadofresco:* \
--cloud-watch-logs-role-arn arn:aws:iam::111122223333:role/rol-cloudtrail-a-logs \
--tags-list Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
Key=Componente,Value=auditoria Key=Propietario,Value=marta \
Key=CentroCoste,Value=plataforma \
--profile mercadofresco-dev --region eu-west-1
# AND START IT: create-trail does NOT start it.
aws cloudtrail start-logging \
--name trail-mercadofresco \
--profile mercadofresco-dev --region eu-west-1The blunder of the year: create-trail creates the trail but does not start recording. You
have to call start-logging. There are accounts with trails created years ago that have never written
a single line. Always check it:
aws cloudtrail get-trail-status --name trail-mercadofresco \
--query '[IsLogging,LatestDeliveryTime,LatestDeliveryError]' \
--profile mercadofresco-dev --region eu-west-1The four flags of the command, explained:
| Flag | What it does | Why? |
|---|---|---|
--is-multi-region-trail |
Records the 19+ regions | An attacker creates resources in ap-south-1, where nobody looks |
--include-global-service-events |
Includes IAM, STS, CloudFront, Route 53 | They are global and are recorded in us-east-1 |
--enable-log-file-validation |
Generates signed digest files | Detects tampering |
--kms-key-id |
Encrypts the files with your key | Separation of duties (04-02) |
The first one is the most important from a security point of view. A single-region trail is a camera that only points at the front door.
The bucket nobody must be able to delete
An audit log the attacker can delete is not an audit log. It is the first thing anybody who knows what they are doing does: get in, wipe the trace, carry on.
There are four levels of protection, and it is worth understanding what each one protects:
| Level | What it prevents | Weakness |
|---|---|---|
1. Bucket policy with Deny |
Deleting objects | Whoever can change the policy removes it |
| 2. Versioning + MFA Delete | Deleting versions without a physical MFA | Only the root account can enable it |
3. Object Lock in COMPLIANCE mode |
Deletion, even by the root account | It has to be enabled when the bucket is created |
| 4. A separate account | The production attacker reaching the bucket | Requires Organizations (09-04) |
Level 3, Object Lock, is the one that really closes the door:
# It can only be enabled on a bucket with versioning.
# On buckets that already exist you have to ask AWS support for it;
# the usual thing is to create it with --object-lock-enabled-for-bucket.
aws s3api put-object-lock-configuration \
--bucket mercadofresco-auditoria-cloudtrail \
--object-lock-configuration '{
"ObjectLockEnabled": "Enabled",
"Rule": {
"DefaultRetention": { "Mode": "COMPLIANCE", "Days": 2555 }
}
}' \
--profile mercadofresco-devThe two modes, and the difference is enormous:
| Mode | Who can shorten the retention |
|---|---|
GOVERNANCE |
Anyone with s3:BypassGovernanceRetention |
COMPLIANCE |
Nobody. Not the root account. Not AWS support. |
Those 2,555 days are 7 years. And that is where the danger lies: with COMPLIANCE, every object
CloudTrail writes will be undeletable for seven years, and you will pay for it for seven years. If you
get the value wrong, there is no way back.
This is exactly the decision a compliance professional must validate. The retention period for audit logs depends on your sector, your country and your contractual obligations. Do not choose it by intuition, and do not copy the 2555 from this course.
Level 2, MFA Delete, can only be enabled by the root account with an MFA device, and that is why it does not fit the 04-01 principle of not using root. MercadoFresco does not use it; it uses Object Lock.
Level 4, a separate account, is the complete professional answer: the audit bucket lives in a
different logging account, which the production account can only write to. Even if somebody fully
compromises 111122223333, the logs are out of their reach. It requires AWS Organizations, which
is lesson 09-04. MercadoFresco has it noted down as the next step.
And a complementary defence you can set up today: an alarm that warns you if somebody stops or deletes the trail. It is in the metric filters section.
Log file integrity validation
With --enable-log-file-validation, CloudTrail does something elegant: every hour it publishes a
digest file containing the SHA-256 hash of every log file for that hour, plus the hash of the
previous digest. It is a chain: each digest signs the one before it.
flowchart LR
D1["Digest 10:00<br/>hash of the files<br/>+ hash of the 09:00 digest"] --> D2["Digest 11:00<br/>hash of the files<br/>+ hash of the 10:00 digest"]
D2 --> D3["Digest 12:00<br/>..."]
F1["registro-10-01.json.gz"] -.-> D1
F2["registro-10-02.json.gz"] -.-> D1
F3["registro-11-01.json.gz"] -.-> D2
Practical consequence: a log file cannot be altered, nor deleted, nor a digest replaced, without breaking the chain. The digests are signed with CloudTrail's private key, so they cannot be regenerated either.
The verification:
aws cloudtrail validate-logs \
--trail-arn arn:aws:cloudtrail:eu-west-1:111122223333:trail/trail-mercadofresco \
--start-time 2026-07-01T00:00:00Z \
--end-time 2026-08-01T00:00:00Z \
--profile mercadofresco-dev --region eu-west-1A healthy output ends with Results requested for ... Results found for N digest files ... All files verified. Any mention of modified or missing files is an immediate security
finding.
Marta runs it on the first Monday of every month and keeps the output. In a formal audit, that output is what proves the logs have not been touched.
Anatomy of an event: the annotated JSON
This is a real kms:Decrypt event, which is exactly the one we were after. Annotated field by
field:
{
"eventVersion": "1.09",
"userIdentity": {
"type": "AssumedRole",
"principalId": "AROA1234567890ABCDEFG:sesion-marta-copias",
"arn": "arn:aws:sts::111122223333:assumed-role/rol-restauracion-copias/sesion-marta-copias",
"accountId": "111122223333",
"accessKeyId": "ASIA1234567890ABCDEF",
"sessionContext": {
"sessionIssuer": {
"type": "Role",
"principalId": "AROA1234567890ABCDEFG",
"arn": "arn:aws:iam::111122223333:role/rol-restauracion-copias",
"accountId": "111122223333",
"userName": "rol-restauracion-copias"
},
"attributes": {
"creationDate": "2026-07-28T03:41:52Z",
"mfaAuthenticated": "false"
}
}
},
"eventTime": "2026-07-28T03:42:17Z",
"eventSource": "kms.amazonaws.com",
"eventName": "Decrypt",
"awsRegion": "eu-west-1",
"sourceIPAddress": "198.51.100.77",
"userAgent": "aws-cli/2.15.30 Python/3.11.8 Linux/6.1.0 exe/x86_64",
"requestParameters": {
"encryptionContext": {
"aws:rds:db-id": "arn:aws:rds:eu-west-1:111122223333:db:mercadofresco-pedidos",
"aws:rds:backup-id": "snapshot-2026-07-27-03-00"
},
"keyId": "arn:aws:kms:eu-west-1:111122223333:key/8f2c1a9b-4d3e-4f6a-9c1b-2e5d7a8f3c04",
"encryptionAlgorithm": "SYMMETRIC_DEFAULT"
},
"responseElements": null,
"requestID": "d3a1f9c2-7b4e-4a8d-9f2c-1e5b3a7d9c04",
"eventID": "c7f2b81a-3d9e-4c5f-8a1b-2d6e4f7a9c31",
"readOnly": true,
"resources": [
{
"accountId": "111122223333",
"type": "AWS::KMS::Key",
"ARN": "arn:aws:kms:eu-west-1:111122223333:key/8f2c1a9b-4d3e-4f6a-9c1b-2e5d7a8f3c04"
}
],
"eventType": "AwsApiCall",
"managementEvent": true,
"recipientAccountId": "111122223333",
"eventCategory": "Management",
"tlsDetails": {
"tlsVersion": "TLSv1.3",
"cipherSuite": "TLS_AES_128_GCM_SHA256",
"clientProvidedHostHeader": "kms.eu-west-1.amazonaws.com"
}
}A field-by-field reading of what this event is telling us:
| Field | Value | What it means here |
|---|---|---|
eventTime |
03:42:17Z |
3:42 in the morning. Anomalous on its own |
userIdentity.type |
AssumedRole |
It was not a direct user: somebody assumed a role |
sessionIssuer.userName |
rol-restauracion-copias |
Which role |
principalId after the : |
sesion-marta-copias |
Who assumed it: the session name |
mfaAuthenticated |
"false" |
No MFA. Second indicator |
sourceIPAddress |
198.51.100.77 |
Where from. You have to check whether it is a known one |
userAgent |
aws-cli/2.15.30 |
From the CLI, not the console. It was a script or a person at a terminal |
eventName |
Decrypt |
The operation |
encryptionContext |
db-id, backup-id |
What was decrypted: the backup of 27 July |
readOnly |
true |
It modified nothing |
errorCode |
(absent) | It succeeded |
tlsDetails |
TLS 1.3 | Connection metadata |
Three fields deserve a separate comment:
responseElementsisnullin read-only operations. In a write operation —CreateBucket,RunInstances— it contains what the API returned: the ID of the resource created. It is the field that tells you what was created.errorCodeanderrorMessageappear only when the call failed.AccessDenied,UnauthorizedOperation,Client.InvalidParameterValue. That CloudTrail also records the calls that fail is one of its most valuable properties: an attacker rattling doors leaves a trail ofAccessDeniedthat is the cleanest reconnaissance signal there is.encryptionContextis what in 04-02 we explained as the "additional authenticated data" of envelope encryption. Here you see its true value: it is what turns a genericDecryptevent into "somebody decrypted the 27 July backup ofmercadofresco-pedidos". Without it, the event would only say that a key had been used.
userIdentity: the six types and how to read them
The userIdentity field is where the answer to "who?" lives, and it takes six different forms:
type |
Means | Where the real name is |
|---|---|---|
Root |
The root account. Always alarm | arn |
IAMUser |
An IAM user with permanent keys | userName |
AssumedRole |
Somebody assumed a role with STS | sessionContext.sessionIssuer.userName + session name |
AWSService |
An AWS service acting on its own | invokedBy |
AWSAccount |
Another AWS account | accountId |
FederatedUser |
Federation with SAML / OIDC / Identity Center | sessionContext |
Unknown |
Not determined | — |
The most common, and the most confusing, is AssumedRole. The MercadoFresco EC2 instance does not
appear as an "instance": it appears as the rol-mercadofresco-tienda role with a session name that is
the instance ID. And when Marta assumes a role from her user, she appears as the role with the session
name she chose.
Out of that comes one of the most useful practices in this whole module: if in AssumeRole you do
not set an identifying session name, CloudTrail will tell you "somebody with the administration role
deleted the bucket" and you will have no way of knowing who. With --role-session-name marta.costa you do:
aws sts assume-role \
--role-arn arn:aws:iam::111122223333:role/rol-restauracion-copias \
--role-session-name marta.costa \
--profile mercadofresco-devIn 04-01 we introduced this in passing. Here you see why it matters: the session name is what turns a shared role into an identifiable person. With AWS IAM Identity Center this happens on its own, with the federated user's email address as the session name.
And a note on AWSService. Many legitimate events have invokedBy with values such as
autoscaling.amazonaws.com or dlm.amazonaws.com. That is AWS acting on your behalf: the ASG
launching an instance, DLM creating a snapshot (02-02). They are not alerts; they are the system
working. Filtering them out properly is half the work of cutting the noise in an investigation.
Who decrypted the last backup
Now for the answer to the module 4 question. The direct query against the 90-day history:
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=Decrypt \
--start-time 2026-07-25T00:00:00Z \
--end-time 2026-07-30T00:00:00Z \
--profile mercadofresco-dev --region eu-west-1 \
--output json \
| jq -r '.Events[]
| select(.CloudTrailEvent | fromjson | .requestParameters.encryptionContext["aws:rds:db-id"] // "" | test("mercadofresco-pedidos"))
| (.CloudTrailEvent | fromjson)
| [.eventTime,
.userIdentity.sessionContext.sessionIssuer.userName // .userIdentity.userName,
(.userIdentity.arn | split("/") | last),
.sourceIPAddress,
.userIdentity.sessionContext.attributes.mfaAuthenticated,
.requestParameters.encryptionContext["aws:rds:backup-id"]]
| @tsv'Real output:
2026-07-27T03:00:14Z rds-backup-service AWSService rds.amazonaws.com - snapshot-2026-07-27 2026-07-27T09:15:41Z rol-mercadofresco-tienda i-0abc123def456 10.0.11.24 false - 2026-07-28T03:42:17Z rol-restauracion-copias sesion-marta-copias 198.51.100.77 false snapshot-2026-07-27
Three lines and three completely different readings:
rds-backup-serviceat 03:00: this is RDS itself encrypting the automatic backup. Normal, it is the backup window we configured in 02-04.rol-mercadofresco-tiendafrom10.0.11.24: an ASG instance, a private IP in the application subnet (03-01), decrypting application data. Normal.rol-restauracion-copiasat 03:42, from198.51.100.77, without MFA, on thesnapshot-2026-07-27backup: this one has to be investigated.
And the questions that follow from it, in this order:
- Is
198.51.100.77a known IP? The office? The VPN? Somebody's home? - Who assumed
rol-restauracion-copias? The session name sayssesion-marta-copias, but that is a string chosen by whoever made the call, not a verified identity. You have to find the matchingAssumeRoleevent to see who really assumed it. - Is there a
RestoreDBInstanceFromDBSnapshotevent nearby? Was the backup restored anywhere? - Was there an
AccessDeniedbefore it, a sign of probing?
This is not an answer, it is the start of an investigation. And that is precisely the lesson: CloudTrail does not tell you whether something is wrong. It tells you what happened, precisely enough for a person to decide. The full investigation is further down.
Management events versus data events
This is the distinction that decides the CloudTrail bill:
| Management events | Data events | |
|---|---|---|
| What they record | Operations on resources | Operations on the content |
| Examples | CreateBucket, RunInstances, AssumeRole, Decrypt |
GetObject, PutObject, Invoke, GetItem |
| Volume | Low: hundreds or thousands a day | Enormous: millions |
| First copy | Free | Always paid |
| Cost | 2 USD per 100,000 events (additional copies) | 0.10 USD per 100,000 events |
| Enabled by default | Yes | No |
The calculation to do before enabling data events on S3. MercadoFresco serves some
40 million requests a month to mercadofresco-catalogo-fotos (product photos, although CloudFront
caches most of them). Recording all of them:
40,000,000 × 0.10 / 100,000 = 40 USD/month, plus the S3 storage for a huge volume of JSON, plus whatever it costs to query it with Athena.
And it adds almost nothing: these are public reads of photos of tomatoes.
The rule is: enable data events with minimum-scope selectors, only on what matters.
aws cloudtrail put-event-selectors \
--trail-name trail-mercadofresco \
--advanced-event-selectors '[
{
"Name": "Full management events",
"FieldSelectors": [
{ "Field": "eventCategory", "Equals": ["Management"] }
]
},
{
"Name": "Only the database backups and the reports",
"FieldSelectors": [
{ "Field": "eventCategory", "Equals": ["Data"] },
{ "Field": "resources.type", "Equals": ["AWS::S3::Object"] },
{ "Field": "resources.ARN", "StartsWith": [
"arn:aws:s3:::mercadofresco-copias-basedatos/",
"arn:aws:s3:::mercadofresco-informes-analitica/"
]}
]
},
{
"Name": "Writes to the catalogue, not reads",
"FieldSelectors": [
{ "Field": "eventCategory", "Equals": ["Data"] },
{ "Field": "resources.type", "Equals": ["AWS::S3::Object"] },
{ "Field": "resources.ARN", "StartsWith": [
"arn:aws:s3:::mercadofresco-catalogo-fotos/"
]},
{ "Field": "readOnly", "Equals": ["false"] }
]
}
]' \
--profile mercadofresco-dev --region eu-west-1What this configuration achieves:
| Bucket | What is recorded | Volume/month | Cost |
|---|---|---|---|
mercadofresco-copias-basedatos |
Everything: who reads a backup is critical | ~2,000 | 0.00 USD |
mercadofresco-informes-analitica |
Everything: Sara's business data | ~15,000 | 0.02 USD |
mercadofresco-catalogo-fotos |
Writes only | ~40,000 | 0.04 USD |
mercadofresco-registros-web |
Nothing | — | 0.00 USD |
| Total | ~0.06 USD/month |
From 40 USD to 0.06 USD without losing anything relevant. That readOnly: false on the catalogue is
the key decision: nobody cares who reads a photo of a tomato; somebody uploading or deleting a
photo does matter, because it is a change to the content of the site.
The resource types available for data events include AWS::S3::Object,
AWS::Lambda::Function, AWS::DynamoDB::Table (module 6), AWS::SQS::Queue (07-01) and several more.
Insights events
CloudTrail Insights analyses the volume of calls and detects anomalous spikes in write activity or in errors. It does not look at the content: it looks at the rate.
Cases it detects well:
- A badly written script that calls
DescribeInstances40,000 times in an hour. - A sudden rise in
AccessDenied: somebody probing permissions. - A spike of
TerminateInstancesat 4 in the morning.
aws cloudtrail put-insight-selectors \
--trail-name trail-mercadofresco \
--insight-selectors '[
{"InsightType": "ApiCallRateInsight"},
{"InsightType": "ApiErrorRateInsight"}
]' \
--profile mercadofresco-dev --region eu-west-1Cost: 0.35 USD per 100,000 management events analysed. With ~150,000 events a month, MercadoFresco pays around 0.53 USD/month. It is one of the cheapest things in this module and one of the earliest to warn you.
The two honest limitations: it needs at least 7 days of baseline before it is any use, and it only detects anomalies of volume, not of intent. A single, perfectly executed malicious call generates no insight at all. That is what the specific alarms in the next section are for.
Multi-region and organisation trails
Multi-region we already enabled with --is-multi-region-trail, and it is worth insisting on why. A
single-region trail is like a security camera that only points at the front door: an attacker who gets
hold of credentials will create their mining instances in ap-southeast-2, where nobody ever looks.
With the multi-region trail, those calls land in the same bucket.
Cost of multi-region: zero. The first copy of the management events is free in every region. There is no reason at all not to enable it.
Organisation trail: if you have several accounts under AWS Organizations, a single trail created in the management account records all the member accounts, and they cannot disable it or see it. It is the right configuration for any organisation with more than one account, and it is lesson 09-04. MercadoFresco has a single account today; separating production, pre-production and logging is a declared goal.
Sending the trail to CloudWatch Logs
The S3 bucket is the archive. To react while it is happening, the events also have to reach CloudWatch Logs, where they can become metrics and alarms (05-01).
# 1. Log group with a finite retention
aws logs create-log-group \
--log-group-name /aws/cloudtrail/mercadofresco \
--profile mercadofresco-dev --region eu-west-1
aws logs put-retention-policy \
--log-group-name /aws/cloudtrail/mercadofresco \
--retention-in-days 90 \
--profile mercadofresco-dev --region eu-west-1Notice: 90 days in CloudWatch Logs, 7 years in S3. The two destinations have different purposes and different retentions. Logs is for alarming and querying the recent past; S3 is the legal archive. Duplicating 7 years in CloudWatch Logs would cost a fortune and add nothing.
The role CloudTrail needs:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "cloudtrail.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"aws:SourceArn": "arn:aws:cloudtrail:eu-west-1:111122223333:trail/trail-mercadofresco"
}
}
}]
}With the permissions policy:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:eu-west-1:111122223333:log-group:/aws/cloudtrail/mercadofresco:log-stream:*"
}]
}Cost: ingesting MercadoFresco's management events comes to about 0.4 GB a month: 0.25 USD. If you enabled high-volume data events this would shoot up; that is another reason for restrictive selectors.
Metric filters and security alarms
This is where CloudTrail and CloudWatch come together and turn into detection, not just recording. These are the five alarms MercadoFresco sets up, and all five are standard in any security audit (they appear in the AWS CIS Benchmark, which we will see in 05-04).
1. Root account use. After 04-01, root should never be used:
aws logs put-metric-filter \
--log-group-name /aws/cloudtrail/mercadofresco \
--filter-name filtro-uso-root \
--filter-pattern '{ $.userIdentity.type = "Root" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != "AwsServiceEvent" }' \
--metric-transformations \
metricName=UsoDeRoot,metricNamespace=MercadoFresco/Seguridad,\
metricValue=1,defaultValue=0 \
--profile mercadofresco-dev --region eu-west-1
aws cloudwatch put-metric-alarm \
--alarm-name mercadofresco-uso-root \
--alarm-description "SOMEBODY HAS USED THE ROOT ACCOUNT" \
--namespace MercadoFresco/Seguridad --metric-name UsoDeRoot \
--statistic Sum --period 300 --evaluation-periods 1 \
--threshold 0 --comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:eu-west-1:111122223333:alertas-mercadofresco \
--profile mercadofresco-dev --region eu-west-1That $.userIdentity.invokedBy NOT EXISTS excludes the events in which an AWS service acts on behalf
of the account, which are legitimate and frequent. Without it, the alarm would be pure noise.
2. Stopping or deleting a trail. The first move of anybody who wants to hide their tracks:
aws logs put-metric-filter \
--log-group-name /aws/cloudtrail/mercadofresco \
--filter-name filtro-cambio-trail \
--filter-pattern '{ ($.eventName = "StopLogging") || ($.eventName = "DeleteTrail") || ($.eventName = "UpdateTrail") || ($.eventName = "PutEventSelectors") }' \
--metric-transformations \
metricName=CambiosEnTrail,metricNamespace=MercadoFresco/Seguridad,\
metricValue=1,defaultValue=0 \
--profile mercadofresco-dev --region eu-west-1With its alarm at threshold 0, exactly like the previous one. This is the most important alarm in this lesson: it is the one that warns you that the audit system is under attack.
3. KMS use on the database backups. The one that directly answers the module 4 question, but while it is happening:
aws logs put-metric-filter \
--log-group-name /aws/cloudtrail/mercadofresco \
--filter-name filtro-descifrado-copias \
--filter-pattern '{ $.eventSource = "kms.amazonaws.com" && $.eventName = "Decrypt" && $.requestParameters.encryptionContext."aws:rds:db-id" = "*mercadofresco-pedidos*" && $.userIdentity.type != "AWSService" }' \
--metric-transformations \
metricName=DescifradoCopias,metricNamespace=MercadoFresco/Seguridad,\
metricValue=1,defaultValue=0 \
--profile mercadofresco-dev --region eu-west-1That $.userIdentity.type != "AWSService" excludes RDS itself encrypting its automatic backups every
night, which is line 1 of our investigation. What is left is human or script activity on a database
backup, and that always deserves a warning.
4. Repeated denied accesses. The trail that reconnaissance leaves behind:
aws logs put-metric-filter \
--log-group-name /aws/cloudtrail/mercadofresco \
--filter-name filtro-accesos-denegados \
--filter-pattern '{ ($.errorCode = "AccessDenied*") || ($.errorCode = "UnauthorizedOperation") }' \
--metric-transformations \
metricName=AccesosDenegados,metricNamespace=MercadoFresco/Seguridad,\
metricValue=1,defaultValue=0 \
--profile mercadofresco-dev --region eu-west-1
aws cloudwatch put-metric-alarm \
--alarm-name mercadofresco-accesos-denegados \
--alarm-description "AccessDenied spike: possible reconnaissance" \
--namespace MercadoFresco/Seguridad --metric-name AccesosDenegados \
--statistic Sum --period 300 --evaluation-periods 2 --datapoints-to-alarm 2 \
--threshold 20 --comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:eu-west-1:111122223333:alertas-mercadofresco \
--profile mercadofresco-dev --region eu-west-1Here the threshold is not 0, and the reason is an honest one: AccessDenied are constant in any
live account. A developer testing something, a tool querying what it cannot reach, an SDK probing. 20
in five minutes sustained over two periods is a signal.
5. Changes to a security group policy or to IAM.
aws logs put-metric-filter \
--log-group-name /aws/cloudtrail/mercadofresco \
--filter-name filtro-cambios-seguridad \
--filter-pattern '{ ($.eventName = "AuthorizeSecurityGroupIngress") || ($.eventName = "CreateAccessKey") || ($.eventName = "AttachRolePolicy") || ($.eventName = "PutBucketPolicy") || ($.eventName = "DeleteBucketPolicy") || ($.eventName = "PutKeyPolicy") }' \
--metric-transformations \
metricName=CambiosDeSeguridad,metricNamespace=MercadoFresco/Seguridad,\
metricValue=1,defaultValue=0 \
--profile mercadofresco-dev --region eu-west-1Threshold 0 at night would be far too noisy during a daytime deployment. MercadoFresco puts it at threshold 0 with a 5-minute period, accepting the noise: it prefers to hear about every security change. That an alarm is informative rather than urgent is a valid decision, as long as it is documented and does not end up being ignored out of habit.
Querying with Athena
To investigate seriously you have to query the S3 files with SQL. Amazon Athena runs SQL directly over the objects in a bucket, without loading anything into any database.
Athena is covered in more depth in module 6 when we talk about analytics. Here we use just enough to investigate CloudTrail, which is its most common use case.
Step 1: the table. The convenient way to create it is from the CloudTrail console ("Create Athena table"), which generates this DDL. It is worth understanding:
CREATE DATABASE IF NOT EXISTS auditoria_mercadofresco;
CREATE EXTERNAL TABLE auditoria_mercadofresco.cloudtrail_mercadofresco (
eventVersion STRING,
userIdentity STRUCT<
type: STRING,
principalId: STRING,
arn: STRING,
accountId: STRING,
userName: STRING,
invokedBy: STRING,
accessKeyId: STRING,
sessionContext: STRUCT<
attributes: STRUCT<
mfaAuthenticated: STRING,
creationDate: STRING>,
sessionIssuer: STRUCT<
type: STRING,
principalId: STRING,
arn: STRING,
accountId: STRING,
userName: STRING>>>,
eventTime STRING,
eventSource STRING,
eventName STRING,
awsRegion STRING,
sourceIPAddress STRING,
userAgent STRING,
errorCode STRING,
errorMessage STRING,
requestParameters STRING,
responseElements STRING,
requestID STRING,
eventID STRING,
readOnly STRING,
resources ARRAY<STRUCT<
arn: STRING,
accountId: STRING,
type: STRING>>,
eventType STRING,
recipientAccountId STRING,
vpcEndpointId STRING
)
PARTITIONED BY (region STRING, anio STRING, mes STRING, dia STRING)
ROW FORMAT SERDE 'com.amazon.emr.hive.serde.CloudTrailSerde'
STORED AS INPUTFORMAT 'com.amazon.emr.cloudtrail.CloudTrailInputFormat'
OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION 's3://mercadofresco-auditoria-cloudtrail/AWSLogs/111122223333/CloudTrail/';Two things to notice in that DDL:
requestParametersandresponseElementsareSTRING, not structures. Their content varies with the event, so they are queried with JSON functions:json_extract_scalar(requestParameters, '$.keyId').PARTITIONED BYis what decides the cost. Athena charges 5 USD per TB scanned. Without partitions, every query reads all the files in the bucket. With partitions and aWHEREon them, it reads only the days you ask for.
Step 2: registering the partitions. Partition projection automates this:
ALTER TABLE auditoria_mercadofresco.cloudtrail_mercadofresco
SET TBLPROPERTIES (
'projection.enabled' = 'true',
'projection.region.type' = 'enum',
'projection.region.values' = 'eu-west-1,us-east-1,eu-central-1',
'projection.anio.type' = 'integer',
'projection.anio.range' = '2026,2030',
'projection.mes.type' = 'integer',
'projection.mes.range' = '1,12',
'projection.mes.digits' = '2',
'projection.dia.type' = 'integer',
'projection.dia.range' = '1,31',
'projection.dia.digits' = '2',
'storage.location.template' =
's3://mercadofresco-auditoria-cloudtrail/AWSLogs/111122223333/CloudTrail/${region}/${anio}/${mes}/${dia}'
);Without this you would have to run MSCK REPAIR TABLE or ALTER TABLE ADD PARTITION every day. With
projection, Athena infers the partitions from the path pattern.
The queries that answer real questions
1. Who assumed rol-mercadofresco-tienda, and when?
SELECT eventtime,
userIdentity.arn AS who,
json_extract_scalar(requestParameters, '$.roleSessionName') AS session_name,
sourceipaddress,
useragent,
errorcode
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio = '2026' AND mes = '07'
AND eventname = 'AssumeRole'
AND json_extract_scalar(requestParameters, '$.roleArn')
LIKE '%rol-mercadofresco-tienda%'
ORDER BY eventtime DESC
LIMIT 100;2. Who read the mfadmin secret? The question we left open in 04-03:
SELECT eventtime,
COALESCE(userIdentity.sessionContext.sessionIssuer.userName,
userIdentity.userName) AS identity,
split_part(userIdentity.arn, '/', 3) AS session_name,
sourceipaddress,
userIdentity.sessionContext.attributes.mfaAuthenticated AS with_mfa,
errorcode
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio = '2026' AND mes = '07'
AND eventsource = 'secretsmanager.amazonaws.com'
AND eventname = 'GetSecretValue'
AND json_extract_scalar(requestParameters, '$.secretId')
LIKE '%mercadofresco/produccion/rds/mfadmin%'
ORDER BY eventtime DESC;What you should expect in the result: the shop role reading it as each instance starts, and
rol-rotacion-mfadmin every 30 days doing the rotation (04-03). Any other identity is a
finding.
3. All denied calls, grouped. The reconnaissance query:
SELECT COALESCE(userIdentity.sessionContext.sessionIssuer.userName,
userIdentity.userName, userIdentity.type) AS identity,
sourceipaddress,
eventsource,
eventname,
count(*) AS attempts,
min(eventtime) AS first_seen,
max(eventtime) AS last_seen
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio = '2026' AND mes = '07'
AND errorcode IN ('AccessDenied', 'AccessDeniedException', 'UnauthorizedOperation')
GROUP BY 1, 2, 3, 4
HAVING count(*) > 5
ORDER BY attempts DESC
LIMIT 50;This query has a very valuable double use, and it is worth underlining:
- Security: an unknown identity with 400
AccessDeniedacross 30 different services in ten minutes is reconnaissance. Somebody is mapping out what they can do. - Operations:
rol-mercadofresco-tiendawith 3,000AccessDeniedons3:GetObjectmeanspol-mercadofresco-tiendais missing a permission and there is a broken feature nobody has reported.AccessDeniedare a detector of misconfiguration too.
4. Out-of-hours activity, which is usually what gives it away.
SELECT eventtime, eventname, eventsource,
COALESCE(userIdentity.sessionContext.sessionIssuer.userName,
userIdentity.userName) AS identity,
sourceipaddress
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio = '2026' AND mes = '07'
AND readonly = 'false'
AND userIdentity.type != 'AWSService'
AND (hour(from_iso8601_timestamp(eventtime)) < 7
OR hour(from_iso8601_timestamp(eventtime)) > 21)
ORDER BY eventtime DESC;Filtering userIdentity.type != 'AWSService' is essential: without it, the output fills up with
automatic backups, DLM snapshots and ASG activity at 3 in the morning, which is completely
normal.
5. Every source IP, so as to spot the unknown ones.
SELECT sourceipaddress,
count(*) AS calls,
count(DISTINCT eventname) AS distinct_actions,
array_agg(DISTINCT COALESCE(userIdentity.sessionContext.sessionIssuer.userName,
userIdentity.userName)) AS identities,
min(eventtime) AS first_seen, max(eventtime) AS last_seen
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio = '2026' AND mes = '07'
AND userIdentity.type != 'AWSService'
AND sourceipaddress NOT LIKE '10.%'
GROUP BY sourceipaddress
ORDER BY calls DESC;An IP that turns up for the first time, makes 12 calls and disappears is far more suspicious than one that has been making 40,000 for months.
6. The decrypted backup query, now in SQL.
SELECT eventtime,
COALESCE(userIdentity.sessionContext.sessionIssuer.userName,
userIdentity.userName) AS identity,
split_part(userIdentity.arn, '/', 3) AS session_name,
sourceipaddress,
json_extract_scalar(requestParameters, '$.encryptionContext."aws:rds:backup-id"') AS backup
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio = '2026' AND mes = '07'
AND eventsource = 'kms.amazonaws.com'
AND eventname = 'Decrypt'
AND userIdentity.type != 'AWSService'
AND requestParameters LIKE '%mercadofresco-pedidos%'
ORDER BY eventtime DESC;Cost control in Athena: every query charges per TB scanned. Always putting in the partition
WHERE (anio, mes, dia) is the difference between 0.01 USD and several euros per query. And
you can set a hard limit per work group:
aws athena update-work-group --work-group primary \
--configuration-updates 'BytesScannedCutoffPerQuery=10737418240' \
--profile mercadofresco-dev --region eu-west-1Ten GiB maximum per query. If somebody writes a SELECT * with no WHERE, the query cancels itself
instead of generating a bill.
CloudTrail Lake
CloudTrail Lake is the managed alternative to setting up Athena by hand: an event data store managed by AWS, with SQL straight from the CloudTrail console and no tables to create.
| S3 + Athena | CloudTrail Lake | |
|---|---|---|
| Configuration | Bucket, policy, table, partitions | One command |
| Querying | SQL (Presto) from Athena | SQL from CloudTrail |
| Retention | Whatever you want in S3 | Up to 10 years |
| Ingestion cost | S3 only (pennies) | ~2.50 USD/GB |
| Query cost | 5 USD/TB scanned | 0.005 USD/GB scanned |
| Total cost | Much lower | Higher |
| Effort | Medium | Minimal |
| Integration | You build it yourself | Native with Organizations |
aws cloudtrail create-event-data-store \
--name lake-mercadofresco \
--retention-period 2555 \
--multi-region-enabled \
--advanced-event-selectors '[{
"Name": "Management",
"FieldSelectors": [{ "Field": "eventCategory", "Equals": ["Management"] }]
}]' \
--profile mercadofresco-dev --region eu-west-1MercadoFresco's decision: it sticks with S3 + Athena. With 0.4 GB of events a month, Lake would cost 1 USD a month in ingestion —hardly dramatic— but the S3 bucket already exists, is already protected with Object Lock and already feeds the 7-year legal archive. Lake is noted down as an option to reconsider if the account grows and building partitions by hand starts to hurt.
With many accounts and teams that are not platform teams, Lake clearly wins: nobody has to learn to create external tables in Athena in order to investigate an incident.
Investigating an incident, step by step
Back to the finding: rol-restauracion-copias decrypted the snapshot-2026-07-27 backup at 03:42
from 198.51.100.77, without MFA. This is the full procedure, and it works as a template for any
investigation.
Step 0. Freeze the evidence. Before anything else.
# Verify that the logs have not been tampered with
aws cloudtrail validate-logs \
--trail-arn arn:aws:cloudtrail:eu-west-1:111122223333:trail/trail-mercadofresco \
--start-time 2026-07-27T00:00:00Z --end-time 2026-07-29T00:00:00Z \
--profile mercadofresco-dev --region eu-west-1
# Copy the files for the period to an investigation prefix
aws s3 sync \
s3://mercadofresco-auditoria-cloudtrail/AWSLogs/111122223333/CloudTrail/eu-west-1/2026/07/28/ \
s3://mercadofresco-auditoria-cloudtrail/investigaciones/inc-2026-07-28/ \
--profile mercadofresco-devThis is done first, always. If the incident ends up in a formal procedure, what matters is not what you discovered but that you can prove the data did not change while you were investigating.
Step 1. Who assumed the role? The session name sesion-marta-copias is free text chosen by whoever
made the call. You have to look for the AssumeRole:
SELECT eventtime, userIdentity.arn AS who_assumed,
userIdentity.type, sourceipaddress, useragent,
userIdentity.sessionContext.attributes.mfaAuthenticated AS with_mfa,
json_extract_scalar(requestParameters, '$.roleSessionName') AS session_name
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio='2026' AND mes='07' AND dia='28'
AND eventname = 'AssumeRole'
AND json_extract_scalar(requestParameters, '$.roleArn') LIKE '%rol-restauracion-copias%'
ORDER BY eventtime;Result: arn:aws:iam::111122223333:user/luis.dev, at 03:41:52, from 198.51.100.77,
mfaAuthenticated: false, agent aws-cli/2.15.30.
Step 2. Reconstruct the whole session. The temporary accessKeyId (ASIA...) identifies the
complete session:
SELECT eventtime, eventsource, eventname, errorcode,
json_extract_scalar(requestParameters, '$.dBInstanceIdentifier') AS instance
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio='2026' AND mes='07' AND dia='28'
AND userIdentity.accessKeyId = 'ASIA1234567890ABCDEF'
ORDER BY eventtime;Result:
| Time | Event | Result |
|---|---|---|
| 03:41:52 | AssumeRole |
OK |
| 03:42:03 | DescribeDBSnapshots |
OK |
| 03:42:17 | kms:Decrypt |
OK |
| 03:42:19 | RestoreDBInstanceFromDBSnapshot |
OK — instance pedidos-copia-luis |
| 03:58:44 | CreateDBSnapshot |
AccessDenied |
| 04:30:12 | DeleteDBInstance |
OK |
Step 3. Interpret. The pattern is coherent and fairly readable: somebody restored a backup, tried
to create a new snapshot (denied), and 45 minutes later deleted the restored instance. There is no
visible exfiltration —no calls to S3, no successful CreateDBSnapshot, no instance left running—.
It looks like legitimate work done badly: restoring a backup to check something, in the small
hours, without telling anyone and without MFA.
Step 4. Confirm it with the person. And here an important warning, because this is where people get it most wrong: CloudTrail says what happened, not why. You never accuse anybody with a log in your hand without talking to them first. The conversation with Luis makes it clear that he was checking whether a backup was restorable after a warning from Sara, and that he did it at night so as not to load the database during production hours. Right intention, wrong procedure.
Step 5. Check that there is nothing else. Even if the explanation adds up, you verify:
SELECT eventtime, eventname, sourceipaddress
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio='2026' AND mes='07'
AND sourceipaddress = '198.51.100.77'
ORDER BY eventtime;If that IP only appears during Luis's working hours over months, it is his home or his office. If it appears once, at 3 in the morning, and never again, the conversation is a different one.
Step 6. Corrective actions. No culprits and with dates, just like the 04-04 post-mortem:
| Action | Owner | Deadline |
|---|---|---|
Require MFA to assume rol-restauracion-copias (aws:MultiFactorAuthPresent condition, 04-01) |
Marta | 3 days |
The mercadofresco-descifrado-copias alarm, already created, verified with a drill |
Marta | 1 day |
| Written procedure: restoring backups is done in pre-production and is announced | Marta | 1 week |
| Scheduled and automated quarterly restore test | Luis | 1 month |
Tag restored instances Entorno=temporal and have them delete themselves at 8 am |
Luis | 1 month |
Look at the last row and the one before it: the right answer to "Luis restored a backup on the quiet" is not to forbid it. It is to make testing restores easy, visible and routine. A restore test is an excellent practice that in 02-04 we explicitly recommended; the problem was the procedure, not the activity.
Relationship with IAM Access Analyzer
CloudTrail is not only for investigating: it is for building correct permissions. IAM Access Analyzer can read your CloudTrail history and generate a least-privilege policy with exactly the actions an identity has really used over a period.
aws accessanalyzer start-policy-generation \
--policy-generation-details '{
"principalArn": "arn:aws:iam::111122223333:role/rol-mercadofresco-tienda"
}' \
--cloud-trail-details '{
"trails": [{
"cloudTrailArn": "arn:aws:cloudtrail:eu-west-1:111122223333:trail/trail-mercadofresco",
"regions": ["eu-west-1"],
"allRegions": false
}],
"accessRole": "arn:aws:iam::111122223333:role/rol-access-analyzer",
"startTime": "2026-06-01T00:00:00Z",
"endTime": "2026-07-31T00:00:00Z"
}' \
--profile mercadofresco-dev --region eu-west-1This closes the circle with 04-01. There we wrote pol-mercadofresco-tienda by hand, reasoning about
what the shop ought to be able to do. With two months of CloudTrail, Access Analyzer says what it
really did, and the difference between the two lists is a gift:
- Actions that are in the policy and are never used: candidates for removal.
- Actions the application attempts and is denied: broken functionality nobody reported.
Access Analyzer does more —finding resources accessible from outside the account, validating policies— but that is its direct relationship with CloudTrail and with what we learned in 04-01.
Retention, cost and cleanup
| Item | Price | MercadoFresco |
|---|---|---|
| Event history, 90 days | Free | — |
| First copy of management events | Free | 0.00 USD |
| Additional copies of management events | 2.00 USD / 100,000 | 0.00 USD (only one trail) |
| Data events | 0.10 USD / 100,000 | 0.06 USD (with selectors) |
| Insights | 0.35 USD / 100,000 analysed | 0.53 USD |
| S3 storage | 0.023 USD/GB/month | ~0.05 USD |
| Ingestion into CloudWatch Logs | ~0.63 USD/GB | 0.25 USD |
| Alarms (5) | 0.10 USD each | 0.50 USD |
| Athena | 5 USD/TB scanned | ~0.20 USD |
| Total | ~1.59 USD/month |
It is one of the cheapest things you can set up in AWS, and the definitive argument: the first copy of multi-region management events is free. There is no technical or economic excuse for not having a trail. A production account without one is always an audit finding.
S3 lifecycle, so that the 7 years are not paid for at standard storage prices:
{
"Rules": [{
"ID": "auditoria-a-frio",
"Status": "Enabled",
"Filter": { "Prefix": "AWSLogs/" },
"Transitions": [
{ "Days": 90, "StorageClass": "STANDARD_IA" },
{ "Days": 365, "StorageClass": "GLACIER_IR" },
{ "Days": 730, "StorageClass": "DEEP_ARCHIVE" }
]
}]
}Watch out for two things: DEEP_ARCHIVE takes up to 12 hours to restore, which can be unacceptable
in an urgent investigation —which is why the first two years stay in fast-access classes—, and if you
have enabled Object Lock in COMPLIANCE mode, the lifecycle rule cannot expire objects before the
retention runs out. It can change their class, not delete them.
Cleanup, if you have set all this up just to practise:
# Stop and delete the trail
aws cloudtrail stop-logging --name trail-mercadofresco \
--profile mercadofresco-dev --region eu-west-1
aws cloudtrail delete-trail --name trail-mercadofresco \
--profile mercadofresco-dev --region eu-west-1
# Alarms and filters
aws cloudwatch delete-alarms --alarm-names \
mercadofresco-uso-root mercadofresco-cambio-trail \
mercadofresco-accesos-denegados mercadofresco-descifrado-copias \
mercadofresco-cambios-seguridad \
--profile mercadofresco-dev --region eu-west-1
# Log group
aws logs delete-log-group --log-group-name /aws/cloudtrail/mercadofresco \
--profile mercadofresco-dev --region eu-west-1
# Lake, if you created it: first remove the deletion protection
aws cloudtrail update-event-data-store \
--event-data-store <ARN> --no-termination-protection-enabled \
--profile mercadofresco-dev --region eu-west-1
aws cloudtrail delete-event-data-store --event-data-store <ARN> \
--profile mercadofresco-dev --region eu-west-1The bucket is not deleted lightly. If you enabled Object Lock in
COMPLIANCEmode, the objects cannot be deleted until the retention expires —not by you, not by the root account, not by AWS support—, and the bucket cannot be removed while it contains objects. That is a desired property, not a bug. Practise Object Lock in a test account and with retentions of days, never of years.
Common Mistakes and Tips
1. Creating the trail and not calling start-logging. create-trail does not start the recording.
Always check with get-trail-status that IsLogging is true.
2. A single-region trail. An attacker creates resources where nobody looks. Multi-region is free and has no downside.
3. An audit bucket that can be deleted. Without a Deny policy, without versioning and without
Object Lock, the log lasts as long as it takes somebody to run one command. And the definitive level is
a separate account (09-04).
4. Enabling data events without selectors. Recording every GetObject in a photo bucket is tens of
USD a month for useless data. Selectors by prefix and readOnly: false.
5. Not setting a session name when assuming a role. CloudTrail will say "somebody with this role",
and there will be no way of knowing who. --role-session-name with the person's name.
6. Looking in CloudTrail for what your application says. It is not there. CloudTrail records calls to the AWS API; your code's errors are in CloudWatch Logs (05-01).
7. Athena queries with no partition WHERE. They scan the whole bucket at 5 USD per TB. Always put
in anio, mes and dia, and set BytesScannedCutoffPerQuery on the work group.
8. Ignoring AccessDenied. They are at once the reconnaissance detector and the detector of
badly configured permissions. Reviewing them weekly finds broken things nobody reported.
9. Never verifying integrity. validate-logs once a month, with the output saved. Without that,
validation is enabled but does nothing for you.
10. Infinite retention in CloudWatch Logs for CloudTrail events. The long archive goes in S3, which costs 20 times less. In Logs, 90 days.
11. Accusing somebody with a log in your hand. CloudTrail says what happened, not why. You investigate, you cross-check and then you talk to the person. A badly handled incident does more damage than the incident.
12. Enabling Object Lock in COMPLIANCE mode with 7 years "just to try it". There is no way back.
Practise with GOVERNANCE and with retentions of days.
13. Assuming that the session name identifies somebody. It is a free string. The real identity is
in the matching AssumeRole event.
Final tip: review CloudTrail when there are no incidents. Half an hour a month running the
queries for unknown IPs, grouped AccessDenied and out-of-hours activity turns up things —broken
permissions, zombie processes, forgotten keys from a former supplier— that otherwise only appear on
the day there is a real problem.
Exercises
The three exercises work on the auditoria_mercadofresco.cloudtrail_mercadofresco table and the
trail-mercadofresco trail defined in this lesson.
Exercise 1: designing the data events strategy
MercadoFresco wants to enable data events, but with judgement and with a budget of 5 USD a month. Real monthly volume:
| Resource | Operations/month | Split |
|---|---|---|
mercadofresco-catalogo-fotos |
40,000,000 | 99.9 % reads |
mercadofresco-copias-basedatos |
2,400 | 60 % writes |
mercadofresco-informes-analitica |
180,000 | 70 % reads (Sara) |
mercadofresco-registros-web |
8,000,000 | 99 % writes (the ALB) |
mercadofresco-generar-miniaturas Lambda |
620,000 invocations | — |
mercadofresco-estado-pedido Lambda |
660,000 invocations | — |
Requirements: you must always be able to know who has read or touched a database backup; you must detect whether somebody is downloading the business reports en masse; you must detect whether somebody alters the catalogue photos; the budget is 5 USD.
Write the complete advanced selectors in JSON, work out the cost of each one, justify what you leave out and why, and propose what alarm you would set up on the data events you do record.
Exercise 2: the full investigation
One Monday morning, the mercadofresco-accesos-denegados alarm has fired twice over the weekend.
This is the initial data:
- Saturday 02:14 to 02:31: 340 events with
errorCode = AccessDenied. - All from
sourceIPAddress = 203.0.113.201. - All with
userIdentity.type = "IAMUser",userName = "integracion-proveedor". userAgent:aws-cli/2.9.19 Python/3.9.11 Windows/10.- The services affected:
iam,s3,ec2,rds,secretsmanager,kms,organizations. - Sunday 22:40: 6 events with no
errorCodefrom the same IP and the same user:ListBuckets,GetBucketLocation,ListObjectsV2onmercadofresco-informes-analitica,GetObject× 3.
Write: the exact SQL queries you would run and in what order; what conclusion you draw from each one; what containment actions you would take and in exactly what order; and what the fact that the 340 calls failed but 6 succeeded tells you. State as well what information you will not be able to get from CloudTrail and where you would look for it.
Exercise 3: turning CloudTrail into proactive detection
Marta wants to move from investigating afterwards to detecting as it happens. Define five detections on top of the five alarms in the lesson, aimed at MercadoFresco's real risk:
- Somebody creates a permanent access key for an IAM user (after 04-01, nobody should).
- Somebody modifies the policy of the
alias/mercadofresco-datoskey. - An application role is used from an IP outside the VPC.
- Somebody disables encryption on a bucket or enables public access.
- A resource is created in a region MercadoFresco does not use.
For each one: write the metric filter pattern, decide the alarm threshold and periods, say whether it should wake somebody up in the middle of the night or only generate an email, and explain what false positive you expect and how you would avoid it. For the ones that cannot be solved with a metric filter, state what tool you would use (and say which lesson will cover it).
Solutions
Solution 1
Starting calculation. Recording everything:
| Resource | Events | Cost |
|---|---|---|
| Catalogue | 40,000,000 | 40.00 USD |
| Web logs | 8,000,000 | 8.00 USD |
| Reports | 180,000 | 0.18 USD |
| Lambdas | 1,280,000 | 1.28 USD |
| Backups | 2,400 | 0.00 USD |
| Total | 49.5 M | 49.46 USD |
Ten times the budget, and with 97 % of the spend on reads of photos of tomatoes.
Proposed selectors:
[
{
"Name": "Full management",
"FieldSelectors": [
{ "Field": "eventCategory", "Equals": ["Management"] }
]
},
{
"Name": "Database backups: EVERYTHING",
"FieldSelectors": [
{ "Field": "eventCategory", "Equals": ["Data"] },
{ "Field": "resources.type", "Equals": ["AWS::S3::Object"] },
{ "Field": "resources.ARN", "StartsWith": ["arn:aws:s3:::mercadofresco-copias-basedatos/"] }
]
},
{
"Name": "Business reports: EVERYTHING",
"FieldSelectors": [
{ "Field": "eventCategory", "Equals": ["Data"] },
{ "Field": "resources.type", "Equals": ["AWS::S3::Object"] },
{ "Field": "resources.ARN", "StartsWith": ["arn:aws:s3:::mercadofresco-informes-analitica/"] }
]
},
{
"Name": "Catalogue: WRITES ONLY",
"FieldSelectors": [
{ "Field": "eventCategory", "Equals": ["Data"] },
{ "Field": "resources.type", "Equals": ["AWS::S3::Object"] },
{ "Field": "resources.ARN", "StartsWith": ["arn:aws:s3:::mercadofresco-catalogo-fotos/"] },
{ "Field": "readOnly", "Equals": ["false"] }
]
}
]Resulting cost:
| Selector | Events/month | Cost |
|---|---|---|
| Management | ~150,000 | 0.00 USD (first copy free) |
| Database backups | 2,400 | 0.002 USD |
| Reports | 180,000 | 0.18 USD |
| Catalogue, writes only (0.1 %) | ~40,000 | 0.04 USD |
| Total | 222,400 | ~0.22 USD/month |
Well under the 5 USD and meeting all three requirements.
What is left out and why:
- Catalogue reads (40 M): they are public product photos served by CloudFront. Reading them has no security value whatsoever and costs 40 USD. If one day you needed to analyse access patterns, there are the S3 access logs and the CloudFront ones, far cheaper.
mercadofresco-registros-web(8 M): this is the ALB writing its own logs. Recording that the ALB writes its logs is recursive and useless: 8 USD to confirm what we already know.- Lambda invocations (1.28 M): 1.28 USD, and we already have better visibility of those functions
through the
AWS/Lambdametrics, their logs and the X-Ray traces (05-02). CloudTrail would only say "it was invoked", which is the least informative thing of all.
Alarms on the data events we do record:
# 1. Any human read of a database backup
aws logs put-metric-filter \
--log-group-name /aws/cloudtrail/mercadofresco \
--filter-name filtro-lectura-copias \
--filter-pattern '{ $.eventName = "GetObject" && $.requestParameters.bucketName = "mercadofresco-copias-basedatos" && $.userIdentity.type != "AWSService" }' \
--metric-transformations \
metricName=LecturaCopiasBD,metricNamespace=MercadoFresco/Seguridad,\
metricValue=1,defaultValue=0 \
--profile mercadofresco-dev --region eu-west-1With threshold 0: nobody has any business downloading a database backup without somebody finding out.
# 2. Bulk download of reports: a threshold, not zero
aws cloudwatch put-metric-alarm \
--alarm-name mercadofresco-descarga-masiva-informes \
--namespace MercadoFresco/Seguridad --metric-name DescargaInformes \
--statistic Sum --period 300 --evaluation-periods 1 \
--threshold 500 --comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:eu-west-1:111122223333:alertas-mercadofresco \
--profile mercadofresco-dev --region eu-west-1Here the threshold cannot be 0: Sara reads reports every day, it is her job. 500 objects in five minutes is not reading a report, it is downloading the entire warehouse.
# 3. Writes to the catalogue outside the normal process
# Only rol-mercadofresco-tienda and rol-lambda-miniaturas should write there.
--filter-pattern '{ ($.eventName = "PutObject" || $.eventName = "DeleteObject") && $.requestParameters.bucketName = "mercadofresco-catalogo-fotos" && $.userIdentity.sessionContext.sessionIssuer.userName != "rol-mercadofresco-tienda" && $.userIdentity.sessionContext.sessionIssuer.userName != "rol-lambda-miniaturas" }'This last one is the most valuable pattern of the three: a whitelist of expected identities, alarming on everything else. It is more robust than enumerating the bad, because it also covers what you have not foreseen.
Solution 2
Initial reading of the data. Three very clear signals, before running any query at all:
- 340
AccessDeniedin 17 minutes across 7 different services, includingiamandorganizations. That is not an application with a badly set permission: an application always fails on the same call. This is systematic enumeration of permissions. - An
IAMUserwith a permanent key, exactly what 04-01 says must not exist. A "supplier integration" user is the classic vector: a key created once, dropped into a configuration file, and never rotated in three years. - 20 hours of silence and then 6 successful, surgical calls on the business reports. That spacing is the pattern: first you map out what you can do, then you come back for what works.
Query 1: all of that user's activity, not just the weekend's.
SELECT eventtime, eventsource, eventname, errorcode,
sourceipaddress, useragent, awsregion
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio = '2026'
AND userIdentity.userName = 'integracion-proveedor'
ORDER BY eventtime DESC;What I am after: when it all started, whether that IP had appeared before, and what this user
normally did. If its legitimate activity is a daily PutObject at 06:00 from another IP, everything
from the weekend is an intrusion.
Query 2: what succeeded. It is the only thing that really matters.
SELECT eventtime, eventsource, eventname,
json_extract_scalar(requestParameters, '$.bucketName') AS bucket,
json_extract_scalar(requestParameters, '$.key') AS object_key,
sourceipaddress
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio = '2026' AND mes = '07'
AND userIdentity.userName = 'integracion-proveedor'
AND errorcode IS NULL
ORDER BY eventtime;What I am after: the exact scope of the breach. The 340 failures are noise; the 6 successes are
the incident. This is where the three specific objects downloaded from
mercadofresco-informes-analitica show up, and that determines whether personal data is involved and
whether there is an obligation to notify.
Query 3: the access key, to see whether it was used from other places.
SELECT sourceipaddress, useragent, awsregion,
count(*) AS calls,
min(eventtime) AS first_seen, max(eventtime) AS last_seen
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio = '2026'
AND userIdentity.accessKeyId = 'AKIA...'
GROUP BY 1, 2, 3
ORDER BY first_seen;What I am after: if the key is also used from the supplier's legitimate IP, it is compromised but
still in legitimate use, and disabling it will break an integration. If it has only been used from
203.0.113.201 for the past month, the supplier no longer uses it and disabling it breaks nothing.
Query 4: where that key came from and when.
SELECT eventtime, userIdentity.arn AS who_created_it, sourceipaddress
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE eventname IN ('CreateAccessKey', 'CreateUser')
AND json_extract_scalar(responseElements, '$.accessKey.userName') = 'integracion-proveedor';What I am after: whether the key was created two years ago and never rotated, or last Friday, in which case the compromise runs deeper: somebody already had access and set up a back door.
Query 5: correlation by IP, beyond this user.
SELECT eventtime, userIdentity.arn, eventname, errorcode
FROM auditoria_mercadofresco.cloudtrail_mercadofresco
WHERE anio = '2026'
AND sourceipaddress = '203.0.113.201'
ORDER BY eventtime;What I am after: if that IP has used other identities, the scope is far wider.
Containment, in exactly this order:
| # | Action | Why in this order |
|---|---|---|
| 1 | Disable the key, do not delete it: aws iam update-access-key --status Inactive |
Cuts off access while preserving the evidence. Deleting it destroys information |
| 2 | Freeze the evidence: validate-logs + copy the files for the period |
Before touching anything else |
| 3 | Review what permissions the user had and what it could have done | Defines the worst possible case |
| 4 | Determine exactly what was downloaded (query 2) | Decides whether notification is mandatory |
| 5 | Tell management and compliance | If personal data is involved, there are legal deadlines |
| 6 | Look for persistence: CreateUser, CreateAccessKey, CreateRole, PutUserPolicy in the period |
An attacker leaves back doors |
| 7 | Rotate related credentials and review the mfadmin secret |
In case there was lateral movement |
| 8 | Only then, contact the supplier | They may be the one who has been compromised |
What it means that 340 failed and 6 succeeded. It is the best possible news: least privilege worked. The work done in 04-01 is what stopped those 340 calls —to IAM, to KMS, to Secrets Manager, to RDS, to Organizations— from doing any damage. The breach was limited to the permissions that user legitimately had, which were to read reports.
And at the same time it is the worst news about the design: that user should never have had a
permanent key. A supplier's access should use a role assumable with an external identity condition
(sts:ExternalId, 04-01), with temporary credentials that expire on their own. That is the deep fix.
What CloudTrail CANNOT tell you:
| Question | Where to look |
|---|---|
| What exactly did the downloaded objects contain? | In the bucket itself, looking at those keys |
| How was the key leaked? | Code repositories, laptops, the supplier |
| Was the database accessed with stolen credentials? | PostgreSQL logs (05-01) |
| Was anything exfiltrated over the network from an instance? | VPC Flow Logs (03-01) |
| Who is the person behind that IP? | Nobody at AWS. That is police work |
| Was anything changed inside the application? | Application logs (05-01) |
That table is the most important lesson of the exercise: CloudTrail is one piece of the investigation, not the whole investigation. It gives you the "who, what, when, from where" of the AWS control plane. Everything else is in other sources, and that is why this whole module makes sense as a set.
Solution 3
Detection 1: creation of a permanent access key.
{ ($.eventName = "CreateAccessKey") || ($.eventName = "CreateUser") || ($.eventName = "CreateLoginProfile") }| Parameter | Value | Justification |
|---|---|---|
| Threshold | 0 | After 04-01, none should be created |
| Period / evaluation | 300 s / 1 | Immediate |
| Wakes somebody? | Yes | It is the classic persistence step after a compromise |
| Expected false positive | A legitimate new user being set up | It is announced beforehand on the team channel; the notice is marked as expected |
Detection 2: change to the KMS key policy.
{ ($.eventSource = "kms.amazonaws.com") && (($.eventName = "PutKeyPolicy") || ($.eventName = "ScheduleKeyDeletion") || ($.eventName = "DisableKey") || ($.eventName = "DisableKeyRotation")) }| Parameter | Value | Justification |
|---|---|---|
| Threshold | 0 | Changing the policy of alias/mercadofresco-datos is an event of the highest impact |
| Wakes somebody? | Yes, always | ScheduleKeyDeletion on that key would leave the backups and the database unreadable. It is the most destructive event in the whole account |
| False positive | A planned change | It is done with prior notice and it is recorded |
It is worth underlining: in 04-02 we saw that deleting a KMS key has a waiting period of 7 to 30 days precisely to give you time to react. This alarm is what turns that waiting period into a real defence: without it, the wait goes by without anybody noticing.
Detection 3: an application role used from outside the VPC.
A metric filter cannot do this properly. The CloudWatch Logs pattern syntax does not reliably support IP range comparisons or prefix negation. You can approximate it:
{ ($.userIdentity.sessionContext.sessionIssuer.userName = "rol-mercadofresco-tienda") && ($.sourceIPAddress != "10.0.*") }…but it is fragile and will generate false positives, because calls through a VPC endpoint
(vpce-mercadofresco-s3) or from certain services show up with other addresses.
The right tool is a different one, and here we have to be honest:
- Amazon GuardDuty has a specific finding for this:
UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration, which detects instance role credentials used from outside AWS. It is exactly this case and it works with no configuration. - Alternatively, a condition in the role's trust policy with
aws:SourceVpc, which outright prevents use from outside instead of detecting it. Preventing is better than detecting (04-01).
GuardDuty and Security Hub are covered in overview in 05-04.
Detection 4: encryption disabled or public access on a bucket.
{ ($.eventSource = "s3.amazonaws.com") && (($.eventName = "DeleteBucketEncryption") || ($.eventName = "PutBucketAcl") || ($.eventName = "DeletePublicAccessBlock") || ($.eventName = "PutBucketPolicy")) }| Parameter | Value | Justification |
|---|---|---|
| Threshold | 0 | None of those four should happen in production |
| Wakes somebody? | Immediate email; SMS only if it is about critical buckets | PutBucketPolicy happens legitimately when deploying |
| False positive | Infrastructure-as-code deployments | Filtered by identity: if it comes from the pipeline role (module 8), no alarm |
But this detection has a serious limit: it only detects the change, not the state. If encryption was already disabled before the alarm was created, it will never fire. And it cannot fix it.
There is a specific tool for this, and it is the next lesson: AWS Config. Config evaluates the current state of every resource against rules, detects the deviations that already exist —not only the new ones— and can fix them automatically. It is exactly the fifth question module 4 left open.
Detection 5: a resource created in a region that is not used.
{ ($.awsRegion != "eu-west-1") && ($.awsRegion != "us-east-1") && ($.readOnly = "false") && ($.userIdentity.type != "AWSService") }| Parameter | Value | Justification |
|---|---|---|
| Threshold | 0 | MercadoFresco only uses eu-west-1 and us-east-1 (CloudFront, WAF, ACM) |
| Wakes somebody? | Yes | Creating resources in unused regions is the classic signature of cryptocurrency mining with stolen credentials |
| False positive | Somebody trying something out | Very rare, and it deserves the conversation |
This detection depends entirely on the trail being multi-region. Without --is-multi-region-trail
you would see absolutely nothing, and it is the definitive argument for enabling it.
And prevention, better than detection: a service control policy (SCP) that denies everything outside the permitted regions. It requires Organizations: 09-04.
Summary of the five:
| # | Detection | Metric filter? | Right tool |
|---|---|---|---|
| 1 | Access key created | Yes | CloudTrail + alarm |
| 2 | KMS policy modified | Yes | CloudTrail + alarm |
| 3 | Role used outside the VPC | No | GuardDuty, or a condition in the trust policy |
| 4 | Bucket encryption / public access | Partial | AWS Config (05-04) |
| 5 | Resource in an unused region | Yes | Multi-region CloudTrail + alarm; better still an SCP (09-04) |
The conclusion of the exercise: CloudTrail detects events, not states. It is perfect for "somebody has done X", and insufficient for "bucket Y has been misconfigured for three months". That second question needs a different tool, and it is the next lesson.
Conclusion
MercadoFresco now knows who did what. You are clear about the essential difference that defines this service: CloudTrail records calls to the AWS API; CloudWatch Logs records what your application says. One answers "who deleted the bucket?", the other "why did the payment fail?". They do not compete: they cover different universes, and searching the wrong one costs hours.
You know that the 90-day event history is always there, free, and you know its five limitations
—90 days, management only, one attribute per search, no SQL, no immutability— which are exactly the
reasons for creating a trail. You have created trail-mercadofresco with the four flags that
matter: multi-region (free, and without it an attacker creates resources where nobody looks),
global events, integrity validation and encryption with alias/mercadofresco-datos.
And you know that create-trail does not start the recording: you have to call start-logging
and check it with get-trail-status.
You have set up the mercadofresco-auditoria-cloudtrail bucket with the four layers of protection —a
policy with a Deny on deletion, versioning, Object Lock in COMPLIANCE mode that not even the
root account can get around, and the separate account as the step still pending from 09-04— because an
audit log the attacker can delete is not an audit log. And you know the chain of signed digests that
makes it impossible to alter a file without breaking it, with validate-logs run on the first Monday
of every month.
You can read a complete event: eventTime, eventSource, eventName, sourceIPAddress,
userAgent, requestParameters, responseElements (null on reads) and errorCode, which appears
only when the call failed —and which is one of CloudTrail's most valuable properties, because an
attacker rattling doors leaves a trail of AccessDenied—. You have mastered the six types of
userIdentity, with AssumedRole as the most frequent and the most confusing, and you know that
the session name is the only thing that turns a shared role into an identifiable person, and that
it is free text: the real identity is in the matching AssumeRole.
And you have answered module 4's third question. Who decrypted the last backup: three Decrypt
events, two of them normal —RDS encrypting its automatic backup and an ASG instance from
10.0.11.24— and one that was not: rol-restauracion-copias, at 03:42, from
198.51.100.77, without MFA, on snapshot-2026-07-27. Thanks to the encryptionContext from
04-02, which is what turns a generic KMS event into "somebody decrypted the 27 July backup of
mercadofresco-pedidos".
You can tell management events (first copy free) from data events (0.10 USD per 100,000, and volumes in the millions), and you have written advanced selectors that bring the bill down from 40 USD to 0.06 USD without losing anything: everything on the backups and the reports, writes only on the catalogue, nothing on the web logs. You know Insights at 0.53 USD a month, and its two limitations: 7 days of baseline and it only detects anomalies of volume.
You have sent the trail to CloudWatch Logs as well —90 days there, 7 years in S3, each destination
with its own purpose— and you have set up the five standard security alarms: root account use,
changes to the trail (the most important of them all, because it warns you that the audit is under
attack), backup decryption excluding RDS itself, AccessDenied spikes with a realistic threshold, and
security configuration changes. And you can query with Athena —with the partition WHERE always in
place and BytesScannedCutoffPerQuery as a safety net— to answer who assumed a role, who read the
mfadmin secret, which calls failed and which unknown IPs have shown up; with CloudTrail Lake as
the managed alternative, evaluated and discarded with judgement.
You have walked through a full investigation: freeze the evidence before anything else, find the
real AssumeRole, reconstruct the entire session by accessKeyId, interpret the pattern, talk to
the person before concluding anything —CloudTrail says what happened, not why— and close with
corrective actions with an owner and a date, among them the most important one: making restore tests
easy and visible instead of forbidding them. And you know IAM Access Analyzer generating
least-privilege policies from real usage, which closes the circle with 04-01. All for 1.59 USD a month.
But look at what this service cannot do, because exercise 3 made it clear:
CloudTrail detects events, not states. It knows somebody called DeleteBucketEncryption this
morning. It does not know that the mercadofresco-registros-web bucket has gone five months without
encryption because it was never configured. It does not know there is a security group with 0.0.0.0/0
on port 22 left over from a test in March. It cannot tell you how many resources breach MercadoFresco's
mandatory tagging policy. And it certainly cannot fix it on its own.
That is the fifth and last question module 4 left open: nothing warns you if somebody disables the
encryption of a bucket or opens a security group to the world. In lesson 05-04, "AWS Config", we
will see what a resource's configuration item and its timeline are, how exactly it differs from
CloudTrail —who made the call versus how the resource ended up—, how the configuration recorder is
enabled with its delivery channel, the managed rules MercadoFresco needs
(s3-bucket-server-side-encryption-enabled, restricted-ssh, required-tags and company), custom
rules with Lambda and with Guard, automatic remediation with Systems Manager documents that
re-encrypts a bucket or closes a security group with nobody intervening, conformance packs aligned
with CIS and PCI DSS, and the real cost per configuration item, which is the line that most easily
runs away in this whole module.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
