Cloud Security Skill-Building - Entry 3
June 21, 2026
What I learned
In this entry, I moved beyond building private cloud infrastructure and focused on three areas:
- detecting suspicious activity
- enforcing least-privilege access
- defining AWS resources through Infrastructure as Code
The goal was to understand how a cloud security engineer connects visibility, detection, prevention, and repeatable deployment.
I built a detection pipeline for S3 object access, tested different exfiltration patterns, tightened IAM permissions, and began rebuilding parts of the environment with Terraform.
What I built
S3 data-event visibility
I started by enabling CloudTrail Data Events for my secure S3 bucket.
CloudTrail management events showed administrative operations such as:
ListBucketsGetBucketAcl- configuration changes
However, management events did not show individual object downloads.
To detect access to files, I needed S3 Data Events, including:
GetObjectPutObjectDeleteObject
After enabling data events, I downloaded an object from an EC2 instance:
aws s3 cp \
s3://david-secure-logs-2026/upload-text.txt \
test.txt
The resulting CloudTrail event showed:
"eventSource": "s3.amazonaws.com",
"eventName": "GetObject",
"awsRegion": "us-west-2",
"sourceIPAddress": "10.0.2.118"
It also identified:
- the IAM role performing the request
- the requested bucket
- the object key
- the AWS CLI user agent
- the number of bytes transferred
This confirmed that I could answer:
- who accessed the object
- which object they accessed
- when they accessed it
- where the request originated
CloudWatch detection pipeline
I configured CloudTrail to deliver new events to a CloudWatch log group:
cloudtrail-s3-data-events
I then used CloudWatch Logs Insights to investigate the events.
One of the queries I used was:
fields @timestamp,
eventName,
userIdentity.sessionContext.sessionIssuer.userName,
sourceIPAddress
| filter eventName = "GetObject"
| sort @timestamp desc
This displayed each object read with its timestamp, IAM role, and source IP.
I also grouped the events by role and five-minute time windows:
fields userIdentity.sessionContext.sessionIssuer.userName as roleName
| filter eventName = "GetObject"
| stats count() as getObjectCount
by roleName, bin(5m) as timeBucket
| sort timeBucket desc
The results showed LogReaderEC2Role performing counts such as:
- 39 reads
- 32 reads
- 34 reads
- 26 reads
across separate five-minute windows.
For a role intended to retrieve logs occasionally, sustained activity at that rate looked more like bulk collection than normal usage.
Burst data-exfiltration detection
I created a CloudWatch metric filter for:
{ $.eventName = "GetObject" }
The filter published a custom metric with the following configuration:
- Namespace:
Security - Metric name:
S3GetObjectCount - Metric value:
1
This converted every matching CloudTrail event into a measurable CloudWatch datapoint.
I then created a burst-detection alarm:
- Statistic:
Sum - Period: 1 minute
- Threshold: greater than 5
- Datapoints to alarm: 1 out of 1
To test it, I generated multiple downloads:
for i in {1..10}; do
aws s3 cp \
s3://david-secure-logs-2026/upload-text.txt \
burst_test_$i.txt
done
After the events reached CloudWatch, the alarm entered the ALARM state.
This validated the complete pipeline:
S3 GetObject
→ CloudTrail Data Event
→ CloudWatch Logs
→ Metric Filter
→ Custom Metric
→ CloudWatch Alarm
Sustained data-exfiltration detection
The first alarm detected short bursts, but an attacker could attempt to avoid it by slowing down requests.
I created a second alarm for sustained activity:
- Statistic:
Sum - Period: 5 minutes
- Threshold: greater than or equal to 25
- Datapoints to alarm: 3 out of 3
This required elevated activity across three consecutive five-minute windows.
I simulated sustained retrieval using:
for i in {1..120}; do
aws s3 cp \
s3://david-secure-logs-2026/upload-text.txt \
slow_test_$i.txt
sleep 8
done
The sustained alarm eventually entered the ALARM state.
The burst alarm also triggered during parts of the test because the request rate was still high enough to exceed its one-minute threshold.
This taught me that detection rules can overlap. A single activity pattern may trigger several rules because each rule evaluates the behavior through a different time window.
IAM least-privilege enforcement
After working on detection, I shifted toward prevention.
I created an IAM policy that allowed s3:GetObject only for objects under:
allowed/*
The policy also explicitly denied reads outside that prefix.
When I attempted to download the original object, the AWS CLI returned:
HeadObject operation: Forbidden
The CLI performed a HeadObject request before downloading the file, so the forbidden response confirmed that access was being blocked.
I then tested the approved path:
s3://david-secure-logs-2026/allowed/test.txt
S3 does not use folders in the same way as a traditional filesystem. The allowed/ portion is simply part of the object key.
To upload the test object, I temporarily allowed:
s3:PutObject
Because the bucket used SSE-KMS, the upload also required KMS permissions such as:
kms:GenerateDataKeykms:Encryptkms:DescribeKey
Without KMS authorization, the upload failed even though S3 allowed PutObject.
After testing the upload and download successfully, I removed the temporary write and KMS permissions so the role returned to read-only access.
IAM source-IP conditions
I then added an aws:SourceIp condition to restrict access based on request origin.
At first, the condition did not enforce the restriction as expected.
The role still had a broader S3 read policy attached. When the conditional allow did not match, the broader policy could still authorize the request.
The existing explicit deny only blocked objects outside allowed/*, so it did not prevent another policy from allowing access to the approved prefix.
I added a second explicit deny using NotIpAddress.
The resulting logic became:
- allow
GetObjectforallowed/*from the approved IP - deny
GetObjectoutsideallowed/* - deny
GetObjectforallowed/*from an unapproved IP
I initially used the public IP shown on the EC2 page, but the request was denied. I checked the source IP AWS actually observed, updated the policy, and successfully downloaded the file.
This reinforced an important IAM rule:
A conditional allow does not create an effective boundary when another broader allow still applies. An explicit deny may be required to enforce the restriction.
Infrastructure as Code with Terraform
I also began rebuilding parts of the environment through Terraform.
I defined resources for:
- an S3 bucket
- S3 versioning
- an IAM policy
- an IAM role
- an IAM policy attachment
- an IAM instance profile
- a planned EC2 instance
I learned the basic Terraform block structure:
resource "RESOURCE_TYPE" "LOCAL_NAME" {
configuration = "value"
}
For example:
resource "aws_iam_role" "log_reader_role" {
name = "terraform-log-reader-role"
}
In this structure:
resourceidentifies the type of Terraform blockaws_iam_roleis the AWS provider resource typelog_reader_roleis the local Terraform reference name- the block body contains the configuration
I practiced the standard Terraform workflow:
terraform init
terraform plan
terraform apply
terraform state list
terraform destroy
The commands served different purposes:
initprepared the directory and downloaded the AWS providerplanpreviewed proposed infrastructure changesapplycreated the approved resourcesstate listshowed the resources Terraform manageddestroyremoved the Terraform-managed environment
I later separated the configuration into:
main.tfvariables.tfoutputs.tf
I added variables for the region, bucket name, and environment, along with tags such as:
tags = {
Environment = var.environment
Name = "cloud-security-lab"
ManagedBy = "terraform"
}
I also created outputs for the bucket name and ARN.
This made the configuration more reusable and easier to understand.
Where things broke
CloudTrail showed activity, but not object downloads
I initially saw management and service events but not GetObject.
The issue was not a lack of S3 activity. Object access required CloudTrail Data Events, and those events were delivered to the trail logs rather than appearing like normal management events in Event History.
The CloudTrail log bucket rejected a new trail
The log bucket policy allowed specific existing trail ARNs but did not allow the new trail.
The policy structure was valid, but its AWS:SourceArn condition did not match the new trail.
This showed how exact ARN scoping supports least privilege but also creates operational friction when new resources are introduced.
IAM conditions appeared ineffective
The IP-based conditional allow did not enforce the restriction because another attached policy still allowed S3 reads.
The fix was to understand the full policy evaluation path and add an explicit deny.
S3 uploads failed despite PutObject
The bucket used SSE-KMS encryption. Uploading required both S3 write permission and permission to generate a KMS data key.
This reinforced that encrypted storage often involves authorization across multiple services.
Cost and architecture tradeoffs
I also reviewed my AWS costs.
The largest VPC expense came from Interface VPC Endpoints for services such as:
- SSM
- SSM Messages
- EC2 Messages
- STS
These endpoints were billed hourly.
The S3 Gateway Endpoint did not have the same hourly endpoint cost.
I also had charges from:
- two running EC2 instances
- EBS storage
- a public IPv4 address
- a customer-managed KMS key
- CloudTrail data events
To reduce costs, I:
- stopped the private EC2 instance
- used the public test instance for later exercises
- removed unnecessary Interface Endpoints
- avoided deploying an additional Terraform EC2 instance
- destroyed temporary Terraform resources after validation
This demonstrated that secure architecture involves tradeoffs between:
- isolation
- convenience
- availability
- operational cost
Final architecture
The detection path was:
EC2
→ S3 GetObject
→ CloudTrail Data Event
→ CloudWatch Logs
→ Metric Filter
→ Custom Metric
→ CloudWatch Alarm
The access-control path was:
IAM Role
→ Prefix Restriction
→ Source-IP Condition
→ Explicit Deny
→ S3 Object
→ KMS Authorization
The Terraform workflow was:
Write
→ Initialize
→ Plan
→ Review
→ Apply
→ Verify
→ Destroy
Key takeaways
This entry taught me that cloud security requires several layers working together:
- prevention
- visibility
- detection
- identity context
- validation
- repeatable deployment
- cost management
I learned that enabling CloudTrail does not automatically provide visibility into every important action. S3 object access requires Data Events.
I learned that logs alone are not detections. They must be queried, converted into signals, and evaluated against meaningful conditions.
I learned that IAM policies cannot be evaluated in isolation. A restrictive statement may not be effective if another broader policy still grants access.
Terraform introduced a more professional infrastructure workflow by allowing changes to be reviewed before deployment and reproduced consistently.
The biggest shift was moving from configuring individual AWS services toward understanding how detection, IAM enforcement, encrypted storage, cost, and Infrastructure as Code fit together as one security architecture.