Back to Blog

AWS CloudTrail Audit Logging: Setup, Athena Queries, and Alerting on Sensitive API Calls

Complete guide to AWS CloudTrail for security auditing. Set up multi-region trails, query logs with Athena, detect sensitive API calls like root login and IAM changes, and alert via SNS.

Viprasol Tech Team
March 4, 2027
13 min read

Every AWS API call in your account is an audit event. Who created that IAM role? When did someone delete the S3 bucket? Which Lambda function assumed that cross-account role at 2 AM? Without CloudTrail, you have no answers. With it properly configured, you have a searchable, tamper-resistant log of everything.

CloudTrail is mandatory for any production AWS workload. It's required for SOC 2, PCI-DSS, HIPAA, and ISO 27001 compliance. This guide covers everything from initial setup to running Athena queries against your trails and alerting on suspicious API calls.

What CloudTrail Records

CloudTrail records three types of events:

  1. Management events (control plane): API calls that change AWS resources — CreateBucket, PutRolePolicy, RunInstances. Enabled by default when you create a trail.

  2. Data events (data plane): Object-level operations within services — S3 GetObject/PutObject, Lambda invocations, DynamoDB reads/writes. Not enabled by default (high volume).

  3. Insights events: Unusual activity detection — abnormal API call rates, error rates. Optional, billed separately.

Terraform Setup: Production-Grade Trail

# cloudtrail/main.tf

locals {
  trail_name    = "${var.project}-cloudtrail"
  bucket_name   = "${var.project}-cloudtrail-logs-${data.aws_caller_identity.current.account_id}"
}

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# S3 bucket for CloudTrail logs
resource "aws_s3_bucket" "cloudtrail" {
  bucket        = local.bucket_name
  force_destroy = false  # NEVER set true in prod — prevents accidental deletion
}

resource "aws_s3_bucket_versioning" "cloudtrail" {
  bucket = aws_s3_bucket.cloudtrail.id
  versioning_configuration { status = "Enabled" }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "cloudtrail" {
  bucket = aws_s3_bucket.cloudtrail.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.cloudtrail.arn
    }
    bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_public_access_block" "cloudtrail" {
  bucket                  = aws_s3_bucket.cloudtrail.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_lifecycle_configuration" "cloudtrail" {
  bucket = aws_s3_bucket.cloudtrail.id

  rule {
    id     = "archive-and-expire"
    status = "Enabled"

    transition {
      days          = 90
      storage_class = "GLACIER_IR"
    }

    expiration {
      days = 2557  # 7 years — common compliance requirement
    }
  }
}

# Bucket policy required by CloudTrail
resource "aws_s3_bucket_policy" "cloudtrail" {
  bucket = aws_s3_bucket.cloudtrail.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AWSCloudTrailAclCheck"
        Effect = "Allow"
        Principal = { Service = "cloudtrail.amazonaws.com" }
        Action   = "s3:GetBucketAcl"
        Resource = "arn:aws:s3:::${local.bucket_name}"
        Condition = {
          StringEquals = {
            "AWS:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${local.trail_name}"
          }
        }
      },
      {
        Sid    = "AWSCloudTrailWrite"
        Effect = "Allow"
        Principal = { Service = "cloudtrail.amazonaws.com" }
        Action   = "s3:PutObject"
        Resource = "arn:aws:s3:::${local.bucket_name}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
        Condition = {
          StringEquals = {
            "s3:x-amz-acl" = "bucket-owner-full-control"
            "AWS:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${local.trail_name}"
          }
        }
      }
    ]
  })
}

# KMS key for CloudTrail encryption
resource "aws_kms_key" "cloudtrail" {
  description             = "CloudTrail log encryption key"
  deletion_window_in_days = 30
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "Enable IAM User Permissions"
        Effect = "Allow"
        Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" }
        Action   = "kms:*"
        Resource = "*"
      },
      {
        Sid    = "Allow CloudTrail to encrypt logs"
        Effect = "Allow"
        Principal = { Service = "cloudtrail.amazonaws.com" }
        Action = ["kms:GenerateDataKey*", "kms:DescribeKey"]
        Resource = "*"
        Condition = {
          StringEquals = {
            "AWS:SourceArn" = "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${local.trail_name}"
          }
          StringLike = {
            "kms:EncryptionContext:aws:cloudtrail:arn" = "arn:aws:cloudtrail:*:${data.aws_caller_identity.current.account_id}:trail/*"
          }
        }
      },
      {
        Sid    = "Allow Athena to decrypt"
        Effect = "Allow"
        Principal = { AWS = aws_iam_role.athena_cloudtrail.arn }
        Action = ["kms:Decrypt", "kms:GenerateDataKey"]
        Resource = "*"
      }
    ]
  })
}

resource "aws_kms_alias" "cloudtrail" {
  name          = "alias/${var.project}-cloudtrail"
  target_key_id = aws_kms_key.cloudtrail.key_id
}

# CloudWatch Log Group for real-time analysis
resource "aws_cloudwatch_log_group" "cloudtrail" {
  name              = "/aws/cloudtrail/${var.project}"
  retention_in_days = 90
  kms_key_id        = aws_kms_key.cloudtrail.arn
}

resource "aws_iam_role" "cloudtrail_cloudwatch" {
  name = "${var.project}-cloudtrail-cw-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "cloudtrail.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "cloudtrail_cloudwatch" {
  role = aws_iam_role.cloudtrail_cloudwatch.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
      Resource = "${aws_cloudwatch_log_group.cloudtrail.arn}:*"
    }]
  })
}

# The CloudTrail itself
resource "aws_cloudtrail" "main" {
  name                          = local.trail_name
  s3_bucket_name                = aws_s3_bucket.cloudtrail.id
  include_global_service_events = true  # IAM, STS, CloudFront
  is_multi_region_trail         = true  # catches all regions
  enable_log_file_validation    = true  # tamper detection
  kms_key_id                    = aws_kms_key.cloudtrail.arn
  cloud_watch_logs_group_arn    = "${aws_cloudwatch_log_group.cloudtrail.arn}:*"
  cloud_watch_logs_role_arn     = aws_iam_role.cloudtrail_cloudwatch.arn

  # Record S3 data events for sensitive buckets
  event_selector {
    read_write_type           = "All"
    include_management_events = true

    data_resource {
      type   = "AWS::S3::Object"
      values = ["arn:aws:s3:::${var.sensitive_bucket_name}/"]
    }
  }

  # Record Lambda invocations
  event_selector {
    read_write_type           = "WriteOnly"
    include_management_events = false

    data_resource {
      type   = "AWS::Lambda::Function"
      values = ["arn:aws:lambda"]  # All functions
    }
  }

  # CloudTrail Insights for anomaly detection
  insight_selector {
    insight_type = "ApiCallRateInsight"
  }

  insight_selector {
    insight_type = "ApiErrorRateInsight"
  }

  tags = var.common_tags

  depends_on = [
    aws_s3_bucket_policy.cloudtrail,
    aws_cloudwatch_log_group.cloudtrail,
  ]
}

☁️ Is Your Cloud Costing Too Much?

Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.

  • AWS, GCP, Azure certified engineers
  • Infrastructure as Code (Terraform, CDK)
  • Docker, Kubernetes, GitHub Actions CI/CD
  • Typical audit recovers $500–$3,000/month in savings

Athena Setup for Log Queries

CloudTrail logs land in S3 as gzipped JSON. Athena lets you query them with SQL:

# athena.tf

resource "aws_athena_database" "cloudtrail" {
  name   = "cloudtrail_logs"
  bucket = aws_s3_bucket.athena_results.bucket
}

resource "aws_glue_catalog_table" "cloudtrail" {
  name          = "cloudtrail_events"
  database_name = aws_athena_database.cloudtrail.name

  table_type = "EXTERNAL_TABLE"

  parameters = {
    EXTERNAL              = "TRUE"
    "projection.enabled"  = "true"
    "projection.dt.type"  = "date"
    "projection.dt.range" = "2024/01/01,NOW"
    "projection.dt.format" = "yyyy/MM/dd"
    "projection.dt.interval"       = "1"
    "projection.dt.interval.unit"  = "DAYS"
    "storage.location.template"    = "s3://${local.bucket_name}/AWSLogs/${data.aws_caller_identity.current.account_id}/CloudTrail/$${region}/$${dt}/"
    "projection.region.type"       = "enum"
    "projection.region.values"     = "us-east-1,us-west-2,eu-west-1,ap-southeast-1"
  }

  storage_descriptor {
    location      = "s3://${local.bucket_name}/AWSLogs/${data.aws_caller_identity.current.account_id}/CloudTrail/"
    input_format  = "com.amazon.emr.cloudtrail.CloudTrailInputFormat"
    output_format = "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat"

    ser_de_info {
      serialization_library = "com.amazon.emr.hive.serde.CloudTrailSerde"
    }

    columns {
      name = "eventversion"    type = "string"
    }
    columns { name = "useridentity"    type = "struct<type:string,principalid:string,arn:string,accountid:string,invokedby:string,accesskeyid:string,userName:string,sessioncontext:struct<attributes:struct<mfaauthenticated:string,creationdate:string>,sessionissuer:struct<type:string,principalid:string,arn:string,accountid:string,username:string>>>" }
    columns { name = "eventtime"       type = "string" }
    columns { name = "eventsource"     type = "string" }
    columns { name = "eventname"       type = "string" }
    columns { name = "awsregion"       type = "string" }
    columns { name = "sourceipaddress" type = "string" }
    columns { name = "useragent"       type = "string" }
    columns { name = "errorcode"       type = "string" }
    columns { name = "errormessage"    type = "string" }
    columns { name = "requestparameters" type = "string" }
    columns { name = "responseelements"  type = "string" }
    columns { name = "requestid"         type = "string" }
    columns { name = "eventid"           type = "string" }
    columns { name = "resources"         type = "array<struct<ARN:string,accountId:string,type:string>>" }
    columns { name = "eventtype"         type = "string" }
    columns { name = "apiversion"        type = "string" }
    columns { name = "readonly"          type = "string" }
    columns { name = "recipientaccountid" type = "string" }
    columns { name = "serviceeventdetails" type = "string" }
    columns { name = "sharedeventid"     type = "string" }
    columns { name = "vpcendpointid"     type = "string" }

    partition_keys {
      name = "region"  type = "string"
    }
    partition_keys {
      name = "dt"      type = "string"
    }
  }
}

Essential Athena Security Queries

-- 1. Root account activity (always high severity)
SELECT
  eventtime,
  eventname,
  eventsource,
  sourceipaddress,
  useragent,
  useridentity.type AS identity_type
FROM cloudtrail_logs.cloudtrail_events
WHERE dt BETWEEN '2027/01/01' AND '2027/03/04'
  AND region = 'us-east-1'
  AND useridentity.type = 'Root'
  AND eventtype = 'AwsApiCall'
ORDER BY eventtime DESC
LIMIT 100;

-- 2. Failed authentication attempts (brute force detection)
SELECT
  sourceipaddress,
  errorcode,
  COUNT(*) AS attempt_count,
  MIN(eventtime) AS first_seen,
  MAX(eventtime) AS last_seen,
  ARRAY_AGG(DISTINCT eventname) AS events
FROM cloudtrail_logs.cloudtrail_events
WHERE dt >= '2027/03/01'
  AND errorcode IN ('AccessDenied', 'UnauthorizedOperation', 'AuthFailure')
GROUP BY sourceipaddress, errorcode
HAVING COUNT(*) > 10
ORDER BY attempt_count DESC;

-- 3. IAM privilege escalation attempts
SELECT
  eventtime,
  eventname,
  eventsource,
  sourceipaddress,
  useridentity.arn AS actor_arn,
  useridentity.username AS username,
  requestparameters
FROM cloudtrail_logs.cloudtrail_events
WHERE dt >= '2027/01/01'
  AND eventsource = 'iam.amazonaws.com'
  AND eventname IN (
    'AttachUserPolicy',
    'AttachRolePolicy',
    'CreatePolicy',
    'CreatePolicyVersion',
    'PutUserPolicy',
    'PutRolePolicy',
    'AddUserToGroup',
    'CreateUser',
    'CreateRole',
    'UpdateAssumeRolePolicy'
  )
ORDER BY eventtime DESC
LIMIT 500;

-- 4. Security group and network changes
SELECT
  eventtime,
  eventname,
  sourceipaddress,
  useridentity.arn AS actor_arn,
  requestparameters
FROM cloudtrail_logs.cloudtrail_events
WHERE dt >= '2027/01/01'
  AND eventsource = 'ec2.amazonaws.com'
  AND eventname IN (
    'AuthorizeSecurityGroupIngress',
    'AuthorizeSecurityGroupEgress',
    'RevokeSecurityGroupIngress',
    'CreateSecurityGroup',
    'DeleteSecurityGroup',
    'ModifyInstanceAttribute',
    'CreateNetworkAclEntry',
    'DeleteNetworkAcl'
  )
ORDER BY eventtime DESC
LIMIT 200;

-- 5. S3 bucket policy changes (data exfiltration risk)
SELECT
  eventtime,
  eventname,
  sourceipaddress,
  useridentity.arn AS actor_arn,
  requestparameters
FROM cloudtrail_logs.cloudtrail_events
WHERE dt >= '2027/01/01'
  AND eventsource = 's3.amazonaws.com'
  AND eventname IN (
    'PutBucketPolicy',
    'DeleteBucketPolicy',
    'PutBucketAcl',
    'PutBucketCors',
    'PutBucketPublicAccessBlock',
    'DeletePublicAccessBlock'
  )
ORDER BY eventtime DESC;

-- 6. Console logins with MFA status
SELECT
  eventtime,
  sourceipaddress,
  useridentity.username AS username,
  useridentity.type AS identity_type,
  JSON_EXTRACT_SCALAR(
    responseelements,
    '$.ConsoleLogin'
  ) AS login_result,
  JSON_EXTRACT_SCALAR(
    requestparameters,
    '$.additionalEventData.MFAUsed'
  ) AS mfa_used
FROM cloudtrail_logs.cloudtrail_events
WHERE dt >= '2027/02/01'
  AND eventname = 'ConsoleLogin'
ORDER BY eventtime DESC
LIMIT 200;

-- 7. Cross-account role assumptions
SELECT
  eventtime,
  sourceipaddress,
  useridentity.arn AS assumed_by,
  JSON_EXTRACT_SCALAR(requestparameters, '$.roleArn') AS role_arn,
  JSON_EXTRACT_SCALAR(requestparameters, '$.roleSessionName') AS session_name
FROM cloudtrail_logs.cloudtrail_events
WHERE dt >= '2027/01/01'
  AND eventname = 'AssumeRole'
  AND eventsource = 'sts.amazonaws.com'
  AND useridentity.accountid != recipientaccountid  -- cross-account
ORDER BY eventtime DESC;

-- 8. Secrets Manager and SSM Parameter Store access
SELECT
  eventtime,
  eventname,
  sourceipaddress,
  useridentity.arn AS accessor,
  JSON_EXTRACT_SCALAR(requestparameters, '$.secretId') AS secret_id,
  errorcode
FROM cloudtrail_logs.cloudtrail_events
WHERE dt >= '2027/01/01'
  AND eventsource = 'secretsmanager.amazonaws.com'
  AND eventname IN ('GetSecretValue', 'DescribeSecret', 'DeleteSecret')
ORDER BY eventtime DESC;

⚙️ DevOps Done Right — Zero Downtime, Full Automation

Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.

  • Staging + production environments with feature flags
  • Automated security scanning in the pipeline
  • Uptime monitoring + alerting + runbook automation
  • On-call support handover docs included

CloudWatch Alarms for Real-Time Alerting

# alerts.tf

resource "aws_sns_topic" "security_alerts" {
  name              = "${var.project}-security-alerts"
  kms_master_key_id = aws_kms_key.cloudtrail.id
}

resource "aws_sns_topic_subscription" "security_email" {
  topic_arn = aws_sns_topic.security_alerts.arn
  protocol  = "email"
  endpoint  = var.security_alert_email
}

locals {
  alarm_defaults = {
    period              = 300
    evaluation_periods  = 1
    statistic           = "Sum"
    namespace           = "CloudTrailMetrics"
    alarm_actions       = [aws_sns_topic.security_alerts.arn]
    ok_actions          = [aws_sns_topic.security_alerts.arn]
    treat_missing_data  = "notBreaching"
  }
}

# Metric filters + alarms
resource "aws_cloudwatch_log_metric_filter" "root_login" {
  name           = "RootAccountUsage"
  pattern        = "{ $.userIdentity.type = \"Root\" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != \"AwsServiceEvent\" }"
  log_group_name = aws_cloudwatch_log_group.cloudtrail.name

  metric_transformation {
    name      = "RootAccountUsageCount"
    namespace = "CloudTrailMetrics"
    value     = "1"
    default_value = "0"
  }
}

resource "aws_cloudwatch_metric_alarm" "root_login" {
  alarm_name          = "${var.project}-root-account-usage"
  alarm_description   = "Root account was used — investigate immediately"
  metric_name         = "RootAccountUsageCount"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  threshold           = 1
  namespace           = local.alarm_defaults.namespace
  period              = local.alarm_defaults.period
  evaluation_periods  = local.alarm_defaults.evaluation_periods
  statistic           = local.alarm_defaults.statistic
  alarm_actions       = local.alarm_defaults.alarm_actions
  treat_missing_data  = local.alarm_defaults.treat_missing_data
}

resource "aws_cloudwatch_log_metric_filter" "iam_changes" {
  name           = "IAMPolicyChanges"
  pattern        = "{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=SetDefaultPolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}"
  log_group_name = aws_cloudwatch_log_group.cloudtrail.name

  metric_transformation {
    name          = "IAMPolicyChangesCount"
    namespace     = "CloudTrailMetrics"
    value         = "1"
    default_value = "0"
  }
}

resource "aws_cloudwatch_metric_alarm" "iam_changes" {
  alarm_name          = "${var.project}-iam-policy-changes"
  alarm_description   = "IAM policy changes detected"
  metric_name         = "IAMPolicyChangesCount"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  threshold           = 1
  namespace           = local.alarm_defaults.namespace
  period              = local.alarm_defaults.period
  evaluation_periods  = local.alarm_defaults.evaluation_periods
  statistic           = local.alarm_defaults.statistic
  alarm_actions       = local.alarm_defaults.alarm_actions
  treat_missing_data  = local.alarm_defaults.treat_missing_data
}

resource "aws_cloudwatch_log_metric_filter" "cloudtrail_disabled" {
  name           = "CloudTrailChanges"
  pattern        = "{($.eventName=CreateTrail)||($.eventName=UpdateTrail)||($.eventName=DeleteTrail)||($.eventName=StartLogging)||($.eventName=StopLogging)}"
  log_group_name = aws_cloudwatch_log_group.cloudtrail.name

  metric_transformation {
    name          = "CloudTrailChangesCount"
    namespace     = "CloudTrailMetrics"
    value         = "1"
    default_value = "0"
  }
}

resource "aws_cloudwatch_metric_alarm" "cloudtrail_disabled" {
  alarm_name          = "${var.project}-cloudtrail-configuration-change"
  alarm_description   = "CloudTrail was modified or stopped — possible attempt to cover tracks"
  metric_name         = "CloudTrailChangesCount"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  threshold           = 1
  namespace           = local.alarm_defaults.namespace
  period              = 60  # More aggressive — 1 minute
  evaluation_periods  = 1
  statistic           = local.alarm_defaults.statistic
  alarm_actions       = local.alarm_defaults.alarm_actions
  treat_missing_data  = local.alarm_defaults.treat_missing_data
}

resource "aws_cloudwatch_log_metric_filter" "no_mfa_console" {
  name           = "ConsoleSignInWithoutMFA"
  pattern        = "{ ($.eventName = \"ConsoleLogin\") && ($.additionalEventData.MFAUsed != \"Yes\") && ($.userIdentity.type = \"IAMUser\") && ($.responseElements.ConsoleLogin = \"Success\") }"
  log_group_name = aws_cloudwatch_log_group.cloudtrail.name

  metric_transformation {
    name          = "ConsoleSignInWithoutMFACount"
    namespace     = "CloudTrailMetrics"
    value         = "1"
    default_value = "0"
  }
}

resource "aws_cloudwatch_metric_alarm" "no_mfa_console" {
  alarm_name          = "${var.project}-console-signin-without-mfa"
  alarm_description   = "Console sign-in without MFA detected"
  metric_name         = "ConsoleSignInWithoutMFACount"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  threshold           = 1
  namespace           = local.alarm_defaults.namespace
  period              = local.alarm_defaults.period
  evaluation_periods  = local.alarm_defaults.evaluation_periods
  statistic           = local.alarm_defaults.statistic
  alarm_actions       = local.alarm_defaults.alarm_actions
  treat_missing_data  = local.alarm_defaults.treat_missing_data
}

resource "aws_cloudwatch_log_metric_filter" "s3_bucket_policy_change" {
  name           = "S3BucketPolicyChanges"
  pattern        = "{($.eventSource=s3.amazonaws.com)&&(($.eventName=PutBucketAcl)||($.eventName=PutBucketPolicy)||($.eventName=PutBucketCors)||($.eventName=PutBucketLifecycle)||($.eventName=PutBucketReplication)||($.eventName=DeleteBucketPolicy)||($.eventName=DeleteBucketCors)||($.eventName=DeleteBucketLifecycle)||($.eventName=DeleteBucketReplication))}"
  log_group_name = aws_cloudwatch_log_group.cloudtrail.name

  metric_transformation {
    name          = "S3BucketPolicyChangesCount"
    namespace     = "CloudTrailMetrics"
    value         = "1"
    default_value = "0"
  }
}

resource "aws_cloudwatch_metric_alarm" "s3_bucket_policy_change" {
  alarm_name          = "${var.project}-s3-bucket-policy-change"
  alarm_description   = "S3 bucket policy or ACL was modified"
  metric_name         = "S3BucketPolicyChangesCount"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  threshold           = 1
  namespace           = local.alarm_defaults.namespace
  period              = local.alarm_defaults.period
  evaluation_periods  = local.alarm_defaults.evaluation_periods
  statistic           = local.alarm_defaults.statistic
  alarm_actions       = local.alarm_defaults.alarm_actions
  treat_missing_data  = local.alarm_defaults.treat_missing_data
}

Lambda-Based Alert Enrichment

Raw CloudWatch alarms are terse. A Lambda enricher pulls context and formats a readable Slack message:

// functions/cloudtrail-alert-enricher/handler.ts
import { SNSEvent } from "aws-lambda";
import {
  CloudTrailClient,
  LookupEventsCommand,
} from "@aws-sdk/client-cloudtrail";

const cloudtrail = new CloudTrailClient({});

interface AlarmMessage {
  AlarmName: string;
  NewStateValue: string;
  NewStateReason: string;
  AlarmDescription: string;
  StateChangeTime: string;
}

export async function handler(event: SNSEvent): Promise<void> {
  for (const record of event.Records) {
    const alarm = JSON.parse(record.Sns.Message) as AlarmMessage;

    if (alarm.NewStateValue !== "ALARM") continue;

    // Look up the triggering CloudTrail event
    const lookupResponse = await cloudtrail.send(
      new LookupEventsCommand({
        StartTime: new Date(Date.now() - 15 * 60 * 1000), // last 15 min
        EndTime: new Date(),
        MaxResults: 5,
        LookupAttributes: getAlarmAttributes(alarm.AlarmName),
      })
    );

    const events = lookupResponse.Events ?? [];
    const eventDetails = events
      .map((e) => {
        const detail = e.CloudTrailEvent
          ? JSON.parse(e.CloudTrailEvent)
          : {};
        return {
          time: e.EventTime?.toISOString(),
          name: e.EventName,
          user: detail.userIdentity?.arn ?? detail.userIdentity?.userName,
          ip: detail.sourceIPAddress,
          region: detail.awsRegion,
        };
      })
      .slice(0, 3);

    await sendSlackAlert({
      alarm: alarm.AlarmName,
      description: alarm.AlarmDescription,
      stateChangeTime: alarm.StateChangeTime,
      events: eventDetails,
    });
  }
}

function getAlarmAttributes(alarmName: string) {
  const attributeMap: Record<string, { AttributeKey: string; AttributeValue: string }> = {
    "root-account-usage": { AttributeKey: "Username", AttributeValue: "root" },
    "iam-policy-changes": { AttributeKey: "EventSource", AttributeValue: "iam.amazonaws.com" },
    "cloudtrail-configuration-change": {
      AttributeKey: "EventSource",
      AttributeValue: "cloudtrail.amazonaws.com",
    },
  };

  const key = Object.keys(attributeMap).find((k) => alarmName.includes(k));
  return key ? [attributeMap[key]] : [];
}

async function sendSlackAlert(payload: {
  alarm: string;
  description: string;
  stateChangeTime: string;
  events: Array<{ time?: string; name?: string; user?: string; ip?: string; region?: string }>;
}) {
  const webhookUrl = process.env.SLACK_SECURITY_WEBHOOK!;

  const eventBlocks = payload.events.map((e) => ({
    type: "section",
    fields: [
      { type: "mrkdwn", text: `*Event:* ${e.name}` },
      { type: "mrkdwn", text: `*Actor:* ${e.user ?? "Unknown"}` },
      { type: "mrkdwn", text: `*IP:* ${e.ip ?? "Unknown"}` },
      { type: "mrkdwn", text: `*Region:* ${e.region ?? "Unknown"}` },
      { type: "mrkdwn", text: `*Time:* ${e.time ?? "Unknown"}` },
    ],
  }));

  await fetch(webhookUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      blocks: [
        {
          type: "header",
          text: { type: "plain_text", text: `🚨 Security Alert: ${payload.alarm}` },
        },
        {
          type: "section",
          text: { type: "mrkdwn", text: `*${payload.description}*\n${payload.stateChangeTime}` },
        },
        { type: "divider" },
        ...eventBlocks,
        {
          type: "actions",
          elements: [
            {
              type: "button",
              text: { type: "plain_text", text: "View in CloudTrail" },
              url: `https://console.aws.amazon.com/cloudtrail/home#/events`,
              style: "danger",
            },
          ],
        },
      ],
    }),
  });
}

Cost Estimates

ComponentCost
CloudTrail management events (1 trail)Free for first trail per region
CloudTrail data events$0.10 per 100,000 events
CloudTrail Insights$0.35 per 100,000 events analyzed
S3 storage (90-day active, 7-year Glacier archive)~$5–20/month depending on volume
Athena queries$5 per TB scanned (partition pruning critical)
CloudWatch Logs (90-day retention)~$3–10/month
Lambda enricher invocationsNegligible at alert volumes
Total typical setup$10–50/month

Cost and Timeline Estimates

ScopeTeam SizeTimelineCost Range
Basic CloudTrail + S3 setup1 dev1 day$200–400
Full Terraform trail + Athena + 5 alarms1 dev2–3 days$600–1,200
Full security audit setup + Slack enricher1–2 devs1 week$2,000–4,000
Multi-account org trail + SIEM integration2–3 devs2–3 weeks$6,000–14,000

See Also


Working With Viprasol

CloudTrail is table stakes for production AWS security — but the setup that actually catches threats requires proper multi-region configuration, Athena partition projection, and alarms tuned to avoid alert fatigue. Our cloud team configures security observability for AWS workloads from startup to enterprise scale.

What we deliver:

  • Terraform-managed CloudTrail with KMS encryption, S3 lifecycle, and tamper validation
  • Athena table with partition projection for cost-efficient querying
  • CloudWatch metric filters and alarms for all CIS AWS Foundations Benchmark controls
  • Slack/PagerDuty alert enrichment via Lambda
  • Runbook documentation for incident response

Talk to our team about AWS security architecture →

Or explore our cloud infrastructure services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need DevOps & Cloud Expertise?

Scale your infrastructure with confidence. AWS, GCP, Azure certified team.

Free consultation • No commitment • Response within 24 hours

Viprasol · Big Data & Analytics

Making sense of your data at scale?

Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.