Build a Real Time Threat Detector with IaC

Instead of clicky-clicky, today we'll build a new EventBridge setup for real-time threat detection and deploy our first detector.

CloudSLAW is, and always will be, free. But to help cover costs and keep the content up to date we have an optional Patreon. For $10 per month you get access to our support Discord, office hours, exclusive subscriber content (not labs — those are free — just extras) and more. Check it out!

Prerequisites

  • An AWS organization with accounts named SecurityAudit and TestAccount1, and an OU named Workloads. I mean, are any of you really just dropping into arbitrary labs without doing all the other ones? Really?

The Lesson

In our last lab we built a time-series threat detector using AWS Athena and Lambda, based on CloudTrail logs. When it comes to threat detection, logs are about as foundational as it gets. Heck, back in the old days they were pretty much all we had.

But cloud is awesome, and aside from logs we have real-time events (which I explained using Star Wars LEGO way back in this lab). So far we’ve worked with two types of events: those coming from tools (like Security Hub in that post) and those generated by AWS services, as illustrated in our autoremediation lab, where we triggered based on S3 events sent to CloudTrail. 

Today we will build on those skills and create a new pipeline for CloudTrail event-based threat detectors. But to add a twist, I’ll walk you through the entire process of creating and deploying your own CloudFormation template. Sure, you can skip to the end I suppose, but that’s cheating and cheaters never win! (I mean, outside politics, business, sports, and… well, every other facet of life, I guess. But hey! Sometimes they get caught! I mean… nevermind).

The setup

We will be building the simplest kind of event-based threat detector: one that triggers off a single API call. As powerful as they are, these are also the most prone to being pains in the ass. Because we are triggering on an activity that might be authorized, without any nuance or checking to understand the context.

In other words, these can be very noisy. They aren’t false positives, because they alert on exactly what we asked for, but they can and do alert on legitimate authorized activity.

Today I picked an event I always want to know about: the creation of a new IAM user. Yeah, this could be super noisy most places, but I do know real large enterprises which don’t allow IAM user creation without a documented approval.

To make the lab more interesting, we also want to deploy our threat detector to our Workloads OU, but send out all alarms from our SecurityAudit account using our existing SNS topic. Here’s our design criteria:

As a security administrator I want a real-time alert every time someone creates an IAM user in any of my accounts. I want these alerts to come from my existing SNS topic in my SecurityAudit account.

Okay, translating that user story, we need to do the following:

  • Capture the IAM:CreateUser event in every account.

  • Send that event to our SecurityAudit account.

  • Trigger our SNS topic for the alert.

This is different than how we set up Security Hub, since that service already centralized events for our entire organization. For this one we need to build that centralization ourselves.

I want to be very clear: the objective of this lab is to only teach the foundational technique for capturing these events. In a future lab we will build in more intelligence by routing to Lambda, as in the last lab, instead of directly forwarding all events!

The Lab

To pull this off we will build two CloudFormation templates: one to set up our SecurityAudit account with a new event bus to receive events and forward them to SNS, and another we deploy with StackSets to forward all the events we want centrally.

Instead of hosting a template for you, this time you will build it yourself and host it in your own S3 bucket.

Video Walkthrough

Step-by-Step

We’ll start by building our two templates and storing them locally. Once they’re done, we will build an S3 bucket and upload them. Then you’ll go into each account and deploy the appropriate template.

First, open up a text editor. I recommend one meant for coding/scripting like Sublime Text.

The SecurityAudit template

We need this one first, since we need the event bus set up before we can send it events. As a reminder, CloudFormation can use JSON or YAML; we will use YAML because it’s easier to work with. YAML requires proper indentation. If you run into issues and don't see any obvious errors, check your tabs and spaces.

First start with our usual CloudFormation header and a description so we can remember what this thing is for:

AWSTemplateFormatVersion: "2010-09-09"
Description: >
  EventBridge bus to collect CloudTrail and other events from accounts within the same organization and forward them to SNS.

The recommended practice is to list the template version (despite it being old).

The next section collects our Parameters. They are the variables we enter when we run the template. We could hardcode both of them, since we won’t use this template anywhere else, but this reduces copy/paste errors among students. No, not you — that person sitting next to you, of course.

Parameters:
  OrganizationId:
    Type: String
    Description: AWS Organizations ID (e.g., o-abc123def4)
    AllowedPattern: "^o-[a-z0-9]{10,32}$"
  SnsTopicArn:
    Type: String
    Description: ARN of the SNS topic (e.g., arn:aws:sns:us-west-2:123456789012:SecurityHubAlerts)

Now we start our Resources section, where we tell CloudFormation what to build. I pair our new event bus with the resource policy which allows any account in our Organization to send it events. Notice how we use our parameter for the Organization ID to fill in the blank?

Resources:
  SecurityMonitoringBus:
    Type: AWS::Events::EventBus
    Properties:
      Name: SecurityMonitoring

  AllowOrgPutEvents:
    Type: AWS::Events::EventBusPolicy
    Properties:
      EventBusName: !Ref SecurityMonitoringBus
      StatementId: AllowPutEventsFromOrg
      Action: events:PutEvents
      Principal: "*"
      Condition:
        Type: StringEquals
        Key: aws:PrincipalOrgID
        Value: !Ref OrganizationId

That’s the magic !Ref. Notice we use that substitution twice? The first time is at EventBusName in the resource policy, and we refer to whatever name was assigned when we made the event bus. The second time we refer to our OrganizationId parameter.

Referring to other values in a template is a critical capability for infrastructure as code. In this case we hardcoded the name of our event bus, but you may recall we don’t always know the unique identifier for a resource until AWS creates it and provides the identifier, like an instance ID. Referring to other resources in the same template tells CloudFormation to grab the value it needs. CloudFormation will then figure out the order to build things and wait until the first thing is ready before it creates the second that relies on it (usually — it ain’t like tech is totally reliable, or anything).

Okay, we have our event bus, and other accounts can send it events. Now let’s create a new EventBridge Rule to send those events to SNS:

AllEventsToSnsRule:
    Type: AWS::Events::Rule
    Properties:
      Name: AllEventsToSns
      Description: "Forward all events on the SecurityMonitoring bus to SNS"
      EventBusName: !Ref SecurityMonitoringBus
      State: ENABLED
      EventPattern: |
        {
          "source": [{
            "exists": true
          }]
        }
      Targets:
        - Id: SecurityMonitoringSnsTarget
          Arn: !Ref SnsTopicArn

Putting it all together (because let’s be real, this is the part you’ll copy and paste), we get our full template to create the bus, allow cross-account access, and forward all events to our existing SNS topic:

AWSTemplateFormatVersion: "2010-09-09"
Description: >
  EventBridge bus to collect CloudTrail and other events from accounts within the same organization and forward them to SNS.

Parameters:
  OrganizationId:
    Type: String
    Description: AWS Organizations ID (e.g., o-abc123def4)
    AllowedPattern: "^o-[a-z0-9]{10,32}$"
  SnsTopicArn:
    Type: String
    Description: ARN of the SNS topic (e.g., arn:aws:sns:us-west-2:123456789012:SecurityHubAlerts)

Resources:
  SecurityMonitoringBus:
    Type: AWS::Events::EventBus
    Properties:
      Name: SecurityMonitoring

  AllowOrgPutEvents:
    Type: AWS::Events::EventBusPolicy
    Properties:
      EventBusName: !Ref SecurityMonitoringBus
      StatementId: AllowPutEventsFromOrg
      Action: events:PutEvents
      Principal: "*"
      Condition:
        Type: StringEquals
        Key: aws:PrincipalOrgID
        Value: !Ref OrganizationId

  AllEventsToSnsRule:
    Type: AWS::Events::Rule
    Properties:
      Name: AllEventsToSns
      Description: "Forward all events on the SecurityMonitoring bus to SNS"
      EventBusName: !Ref SecurityMonitoringBus
      State: ENABLED
      EventPattern: |
        {
          "source": [{
            "exists": true
          }]
        }
      Targets:
        - Id: SecurityMonitoringSnsTarget
          Arn: !Ref SnsTopicArn

Save that file locally with the name securityauditeventcollector.template.

The Workloads OU Stackset Template

This one is even shorter. All we need is an EventBridge Rule which matches the CloudTrail Action we want, and forwards that to the central event bus we just created. But there’s a little twist — can you spot it?

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Forward IAM CreateUser events to EventBus in another account (us-west-2)'

Parameters:
  TargetEventBusArn:
    Type: String
    Description: ARN of the target EventBus in another account (us-west-2)
    AllowedPattern: '^arn:aws:events:us-west-2:\d{12}:event-bus/[\w\-]+$'
    ConstraintDescription: Must be a valid EventBus ARN in us-west-2

Conditions:
  IsUSEast1: !Equals [!Ref 'AWS::Region', 'us-east-1']

Resources:
  # IAM Role for EventBridge to assume when putting events to target bus
  EventBridgeRole:
    Type: AWS::IAM::Role
    Condition: IsUSEast1
    Properties:
      RoleName: ThreatDetectorRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: 'sts:AssumeRole'
      Policies:
        - PolicyName: PutEventsToTargetBus
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - 'events:PutEvents'
                Resource: !Ref TargetEventBusArn

  # EventBridge Rule to capture IAM CreateUser events
  IAMCreateUserEventRule:
    Type: AWS::Events::Rule
    Properties:
      Description: Captures IAM CreateUser API calls and forwards to target EventBus
      State: ENABLED
      EventPattern:
        source:
          - aws.iam
        detail-type:
          - AWS API Call via CloudTrail
        detail:
          eventSource:
            - iam.amazonaws.com
          eventName:
            - CreateUser
      Targets:
        - Arn: !Ref TargetEventBusArn
          Id: CrossAccountEventBusTarget
          RoleArn: !If
            - IsUSEast1
            - !GetAtt EventBridgeRole.Arn
            - !Sub 'arn:aws:iam::${AWS::AccountId}:role/ThreatDetectorRole'

Since we want to monitor every region of every account, and CloudTrail events are only generated in each region of each account, we’ll need to push this using StackSets. But there’s one small problem: we can’t create the IAM role twice, since roles are always in us-east-1. This creates an error.

To get around this problem we use the ‘code’ part of infrastructure as code. That Condition block creates something called IsUSEast1 which only evaluate to True when the template is running in us-east-1. Then, in the Resource block for the Role, we use “Condition: IsUSEast1”, which says “only create this in us-east-1”.

Pretty cool, right? The template will create our EventBridge Rule in every region where we run the template, but only create the role in one region.

Now I want you to focus on the event pattern. There’s also a small redundancy in there… can you see it?

We specify source as the originating service for the event, but then within that event we also validate the eventSource. This is optional — technically we only need one or the other. Using both slightly reduces the risk of someone spoofing an event (they can spoof eventSource, but not source).

And one last note: we didn’t create an event bus in every region, just in the one region. One nice capability of EventBridge is that we can forward events across regions and accounts pretty easily.

Okay, save that template as threatdetectors.template.

Why we only forward some events

If you think about it, we could just forward every CloudTrail event to our central bus and build our rules there. In fact, I’ve built that very architecture before. However, only forwarding the events we want is more efficient. Either option works, although technically the option we chose today is a bit cheaper (which is irrelevant at our scale). The drawback is that anyone who can read that EventBridge rule can see what we are looking for. I don’t really have strong feelings either way, but please send your feedback if you have a preference!

Time to Deploy!

Start in your Sign-in portal > SecurityOperations > AdministratorAccess > Organizations. Then copy your Organization ID and paste it into a new text file.

Then go to S3 > Create bucket:

Name itcloudslaw-templates-<your org ID>-<some random characters>” and Create bucket:

Then click your bucket > Permissions > Bucket policy > Edit and copy and paste in this policy. Then replace the bucket ARN and organization ID!!! Make sure you leave “/*” at the end of the ARN:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowOrgReadObjects",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::<bucketName>/*",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalOrgID": "<o-YourOrgId>"
        }
      }
    }
  ]
}

Then Save changes. Then Objects > Upload and upload your two template files:

With those hosted in S3, go back to Objects, then click each file and copy its URL:

Paste both into your open text file, since you’ll need them to use CloudFormation: 

Now we just need to deploy the darn things. To start we need to create the central event bus and our forwarding rule in SecurityAudit. So… close the tab > sign in portal > SecurityAudit > AdministratorAccess > CloudFormation > Create stack > With new resources:

Paste in YOUR template URL for securityauditeventcollector.template:

You’ll need the ARN of your existing SNS topic, which should be arn:aws:sns:us-west-2:<your account ID>:SecurityHubAlerts. Name it SecurityEventCollector > paste in your Organization ID and SNS topic ARN. Remember, if you forget your account ID just click in the upper-right to grab it:

Click through everything else and Submit. Once it’s complete go to EventBridge > Event buses > copy the ARN of your new event bus:

Now close the tab > sign in portal > SecurityOperations > AdministratorAccess > CloudFormation > StackSets > Create StackSet: 

This time paste in your threat detectors.template URL:

Name it ThreatDetectors and paste in the ARN of your Event Bus:

For Set deployment options, choose the following:

  • Deploy to organization

  • Specify regions: us-east-1 and us-west-2

  • Maximum current accounts: 10

  • Failure tolerance: 9

  • Region concurrency: Parallel

Then Submit.

This is now deploying concurrently across all your accounts and regions. It should only take a few minutes. And being an “organization” StackSet, it will automatically deploy into all new accounts. Pretty cool, eh?

Okay, testing this one is optional. The easy way is go into any of your accounts and create an IAM user, but don’t attach any policies. You’ll see an alert within 15 seconds, and I strongly recommend then going back to delete that IAM user.

Lab Key Points

  • Real-time event-based threat detectors must deploy to every account and region where you want to track activity.

  • When hosting in S3 you need a bucket policy to allow access from other accounts in your organization.

  • EventBridge Rules can forward events from one event bus to another, even across regions.

-Rich

Reply

or to participate.