Insert staff information and other details nicely into the body of detection emails from security services

Insert staff information and other details nicely into the body of detection emails from security services

When sending email notifications for security detections in a multi-account environment, the account ID alone does not tell you who is responsible. We will introduce three patterns for automatically inserting responsible party information into the email body using EventBridge and Step Functions.
2026.08.20

This page has been translated by machine translation. View original

Introduction

Hello everyone, this is Akaike.

When you set up email notifications for Security Hub or GuardDuty detections in a multi-account environment, the email may include the account ID, but it doesn't tell you "who is responsible for that account."
So I wanted to include account-specific information such as the responsible person's email address and department in the notification body, and decided to give it a try.

This time, I've summarized 3 patterns for automatically inserting responsible person information into the notification body, including the pros and cons of each. I've also prepared samples that can be deployed directly with Terraform.


In this article, we're sending notifications to a fixed destination via SNS, but in actual operations, a configuration where you use the email address obtained from the account information as the destination and notify the responsible person directly via SES would also be a valid approach.
Since this time we're focusing on "inserting information into the body," the destination is fixed to SNS, but please understand that the same retrieval method can be applied to determining the destination.


Prerequisites

  • You are using AWS Organizations in a multi-account environment
  • Security services such as Security Hub and GuardDuty are enabled with delegation configured

Notification Mechanism and What We Want to Do

A typical email notification flow for security detections looks something like this.
It's a configuration where EventBridge picks up detection events, assembles the email body, and sends emails from SNS via Step Functions.

This email body contains account IDs and resource information included in the detection event.
What we want to do this time is to add account-specific information such as "who is responsible for that account" to this body.

The image looks something like this:

■ Detected Account
Account Name: sample-platform
Contact: platform-team@example.com

(Following this, detection information, target resources, remediation steps, etc...)

Note: Why EventBridge Alone Cannot Achieve This

EventBridge's InputTransformer, which assembles the email body, can only directly insert values contained in the event.
(It cannot perform lookups using values, such as "look up a mapping table using the account ID to convert it to responsible person information.")

Therefore, a "place to perform the conversion process" that looks up responsible person information from the account ID is needed.
Generally, Lambda would be inserted here, but this time we'll call the API directly using Step Functions' AWS SDK integration, implementing it without Lambda.
We'll also use JSONata as the state machine's query language, which allows straightforward value extraction.

Where to Retrieve Responsible Person Information From

The overall flow is common across all patterns and looks like the following:

What differs is the "what to look up using the account ID as a key" part.
There are two main retrieval sources: "use information registered on the AWS account itself (Patterns 1 & 2)" or "use account tags (Pattern 3)."

# Pattern API Used Preparation Required Information Retrievable
1 Account registration email organizations:DescribeAccount None Account name · Registration email address
2 Account alternate contact account:GetAlternateContact Set contact per account Name · Email · Phone · Title
3 Account tags organizations:ListTagsForResource Tag each account Values of any configured tags

Common Terraform Configuration

Across all 3 patterns, only "which API to call" and "the IAM permissions for it" change.
The skeleton of the SNS topic, IAM role, EventBridge rule, and state machine is entirely common, so let's first prepare the common parts all at once.

The files to configure are the following 3:

  • variables.tf: Variable definitions (common)
  • main.tf: Infrastructure body (common)
  • patternN_*.tf: Block to swap out per pattern (state machine definition and IAM policy)

First, the variable definitions:

variables.tf
variable "region" {
  type    = string
  default = "ap-northeast-1"
}

variable "notification_email" {
  type        = string
  description = "Email endpoint for the SNS subscription."
}

Next, the common infrastructure body.
EventBridge rules are needed per detection source product (Security Hub CSPM, Inspector, GuardDuty, etc.), but since the body template and event pattern are exactly the same with only the product name changing, they are consolidated into one using for_each.

main.tf
terraform {
  required_version = ">= 1.15"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.region
}

data "aws_caller_identity" "current" {}

# Create one EventBridge rule per detection source product
locals {
  security_products = {
    cspm      = "Security Hub"
    guardduty = "GuardDuty"
  }
}

# --- SNS ---
resource "aws_sns_topic" "security_alert" {
  name         = "SecurityAlert-sns"
  display_name = "SecurityAlert"
}

resource "aws_sns_topic_subscription" "security_alert" {
  topic_arn = aws_sns_topic.security_alert.arn
  protocol  = "email"
  endpoint  = var.notification_email
}

resource "aws_sns_topic_policy" "security_alert" {
  arn = aws_sns_topic.security_alert.arn
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "AllowStepFunctionsPublish"
      Effect    = "Allow"
      Principal = { Service = "states.amazonaws.com" }
      Action    = "sns:Publish"
      Resource  = aws_sns_topic.security_alert.arn
      Condition = {
        StringEquals = {
          "aws:SourceAccount" = data.aws_caller_identity.current.account_id
        }
      }
    }]
  })
}

# --- Step Functions ---
resource "aws_iam_role" "sfn" {
  name = "SecurityAlert-SecurityService-sfnrole"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "states.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "sfn_sns_publish" {
  name = "sns-publish"
  role = aws_iam_role.sfn.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "sns:Publish"
      Resource = aws_sns_topic.security_alert.arn
    }]
  })
}

# The state machine definition is defined in patternN_*.tf.
resource "aws_sfn_state_machine" "security_alert" {
  name       = "SecurityAlert-SecurityService-sfn"
  type       = "STANDARD"
  role_arn   = aws_iam_role.sfn.arn
  definition = local.state_machine_definition
}

# --- EventBridge ---
resource "aws_iam_role" "eventbridge" {
  name = "SecurityAlert-SecurityService-ebrole"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "events.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "eventbridge_start_execution" {
  name = "start-execution"
  role = aws_iam_role.eventbridge.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "states:StartExecution"
      Resource = aws_sfn_state_machine.security_alert.arn
    }]
  })
}

resource "aws_cloudwatch_event_rule" "security_alert" {
  for_each = local.security_products

  name        = "SecurityAlert-SecurityService-${each.key}-ebrule"
  description = "Route ${each.value} CRITICAL findings (OCSF V2) to Step Functions"
  event_pattern = jsonencode({
    source      = ["aws.securityhub"]
    detail-type = ["Findings Imported V2"]
    detail = {
      findings = {
        activity_name = ["Create"]
        status        = ["New"]
        severity      = [{ "equals-ignore-case" = "critical" }]
        metadata = {
          product = {
            name = [each.value]
          }
        }
      }
    }
  })
}

resource "aws_cloudwatch_event_target" "security_alert" {
  for_each = local.security_products

  rule     = aws_cloudwatch_event_rule.security_alert[each.key].name
  arn      = aws_sfn_state_machine.security_alert.arn
  role_arn = aws_iam_role.eventbridge.arn

  input_transformer {
    input_paths = {
      title        = "$.detail.findings[0].finding_info.title"
      severity     = "$.detail.findings[0].severity"
      account      = "$.detail.findings[0].cloud.account.uid"
      region       = "$.detail.findings[0].cloud.region"
      resourceType = "$.detail.findings[0].resources[0].type"
      resourceId   = "$.detail.findings[0].resources[0].uid"
      findingId    = "$.detail.findings[0].metadata.uid"
      product      = "$.detail.findings[0].metadata.product.name"
      timeDt       = "$.detail.findings[0].time_dt"
    }
    input_template = <<-EOT
      {
        "account": "<account>",
        "subject": "[<severity>] <product> <account> <timeDt>",
        "message": "■ Detection Information\nTitle: <title>\nSeverity: <severity>\nProduct: <product>\n\n■ Target Resource\nAccount ID: <account>\nRegion: <region>\nResource Type: <resourceType>\nResource ID: <resourceId>\n\n■ Finding ID\n<findingId>"
      }
    EOT
  }
}

output "sns_topic_arn" {
  value = aws_sns_topic.security_alert.arn
}

main.tf references local.state_machine_definition, but since its contents and IAM policy change per pattern, they are defined in each of the following patterns.

Pattern 1: Using the Account Registration Email Address

This pattern requires the least preparation.
An AWS account always requires a root user email address to be registered at creation time.

And this email address can be retrieved with organizations:DescribeAccount.

{
  "Account": {
    "Id": "XXXXXXXXXXXX",
    "Name": "sample-platform",
    "Email": "platform-team@example.com",
    "Status": "ACTIVE"
  }
}

Account.Email is the registration email address, and Account.Name is the account name.
For example, if you operate with a shared mailing list registered as the root email, this directly becomes the contact window for that account.

Using the account ID passed from EventBridge ($states.input.account), we call DescribeAccount, and prepend the retrieved Account.Email and Account.Name to the beginning of the original email body.

pattern1_describe_account.tf
locals {
  state_machine_definition = jsonencode({
    QueryLanguage = "JSONata"
    StartAt       = "EnrichAccountInfo"
    States = {
      EnrichAccountInfo = {
        Type     = "Task"
        Resource = "arn:aws:states:::aws-sdk:organizations:describeAccount"
        Arguments = {
          AccountId = "{% $states.input.account %}"
        }
        Assign = {
          subject         = "{% $states.input.subject %}"
          enrichedMessage = "{% ($acct := $states.result.Account; '■ Detected Account Information\\nAccount Name: ' & ($acct.Name ? $acct.Name : '-') & '\\nContact: ' & ($acct.Email ? $acct.Email : '-') & '\\n\\n' & $states.input.message) %}"
        }
        Catch = [{
          ErrorEquals = ["States.ALL"]
          Next        = "PublishToSNS"
          Assign = {
            subject         = "{% $states.input.subject %}"
            enrichedMessage = "{% '■ Detected Account Information\\nContact: Unavailable\\n\\n' & $states.input.message %}"
          }
        }]
        Next = "PublishToSNS"
      }
      PublishToSNS = {
        Type     = "Task"
        Resource = "arn:aws:states:::sns:publish"
        Arguments = {
          TopicArn = aws_sns_topic.security_alert.arn
          Subject  = "{% $subject %}"
          Message  = "{% $enrichedMessage %}"
        }
        End = true
      }
    }
  })
}

resource "aws_iam_role_policy" "sfn_org_read" {
  name = "org-describe-account"
  role = aws_iam_role.sfn.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "organizations:DescribeAccount"
      Resource = "*"
    }]
  })
}

Regarding line breaks within JSONata with \\n: since HCL escaping adds one layer, it's written so that it becomes a backslash + n when passed to jsonencode.

The appeal of this approach is its convenience of being usable without any preparation, but only one registration email can be retrieved.
Since the root email also serves as the destination for billing and important notifications from AWS, it's better to think of it as the "representative contact for the account" rather than an "individual responsible person."

Pattern 2: Using the Account Alternate Contact

If you want to use the contact information of a specific responsible person, Alternate Contacts can be used.
An alternate contact is a contact that can be set on an account separately from the root contact, and there are 3 types based on purpose:

Type Purpose
Security contact Security personnel
Operations contact Operations personnel
Billing contact Billing personnel

https://docs.aws.amazon.com/ja_jp/accounts/latest/reference/manage-acct-update-contact-alternate.html

For security detection notifications, the "Security contact" would be appropriate.
By specifying SECURITY for AlternateContactType in account:GetAlternateContact, you can retrieve the responsible person's name, email, phone, and title.

// GetAlternateContact (SECURITY) response (excerpt)
{
  "AlternateContact": {
    "AlternateContactType": "SECURITY",
    "Name": "Security Team",
    "EmailAddress": "security-team@example.com",
    "PhoneNumber": "000-0000-0000",
    "Title": "Security Operations"
  }
}

The only differences from Pattern 1 are the API called, its arguments, and the fields referenced.

pattern2_alternate_contact.tf
locals {
  state_machine_definition = jsonencode({
    QueryLanguage = "JSONata"
    StartAt       = "EnrichContact"
    States = {
      EnrichContact = {
        Type     = "Task"
        Resource = "arn:aws:states:::aws-sdk:account:getAlternateContact"
        Arguments = {
          AccountId            = "{% $states.input.account %}"
          AlternateContactType = "SECURITY"
        }
        Assign = {
          subject         = "{% $states.input.subject %}"
          enrichedMessage = "{% ($c := $states.result.AlternateContact; '■ Security Contact\\nResponsible Person: ' & ($c.Name ? $c.Name : '-') & '\\nContact: ' & ($c.EmailAddress ? $c.EmailAddress : '-') & '\\n\\n' & $states.input.message) %}"
        }
        Catch = [{
          ErrorEquals = ["States.ALL"]
          Next        = "PublishToSNS"
          Assign = {
            subject         = "{% $states.input.subject %}"
            enrichedMessage = "{% '■ Security Contact\\nContact: Unavailable\\n\\n' & $states.input.message %}"
          }
        }]
        Next = "PublishToSNS"
      }
      PublishToSNS = {
        Type     = "Task"
        Resource = "arn:aws:states:::sns:publish"
        Arguments = {
          TopicArn = aws_sns_topic.security_alert.arn
          Subject  = "{% $subject %}"
          Message  = "{% $enrichedMessage %}"
        }
        End = true
      }
    }
  })
}

resource "aws_iam_role_policy" "sfn_org_read" {
  name = "account-get-alternate-contact"
  role = aws_iam_role.sfn.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "account:GetAlternateContact"
      Resource = "*"
    }]
  })
}

To retrieve the alternate contact for an organization's member account, you call it specifying AccountId from the management account or the Account Management delegated administrator.

While it's convenient to be able to include name and phone number, one important note is that the alternate contact needs to be configured on each account.
Querying an account that has no alternate contact set will result in a ResourceNotFoundException, but since the definition above includes a Catch, the notification itself will not be stopped.

Pattern 3: Using Account Tags

If you want to freely design how responsible person information is stored, Organizations account tags are convenient.
You can store responsible person information as tags on the account and retrieve them with organizations:ListTagsForResource.

First, add tags to the target account:

aws organizations tag-resource \
  --resource-id XXXXXXXXXXXX \
  --tags Key=Owner,Value=Platform Team \
         Key=OwnerContact,Value=platform-team@example.com

The retrieved tags are returned as an array in the form [{Key, Value}, ...], so you extract the value of the desired key from this.
With JSONata, you can easily extract it in one shot with something like $tags[Key='Owner'].Value.

pattern3_account_tags.tf
locals {
  state_machine_definition = jsonencode({
    QueryLanguage = "JSONata"
    StartAt       = "EnrichOwner"
    States = {
      EnrichOwner = {
        Type     = "Task"
        Resource = "arn:aws:states:::aws-sdk:organizations:listTagsForResource"
        Arguments = {
          ResourceId = "{% $states.input.account %}"
        }
        Assign = {
          subject         = "{% $states.input.subject %}"
          enrichedMessage = "{% ($tags := $states.result.Tags; $owner := $tags[Key='Owner'].Value; $contact := $tags[Key='OwnerContact'].Value; '■ Responsible Person\\nResponsible Person: ' & ($owner ? $owner : '-') & '\\nContact: ' & ($contact ? $contact : '-') & '\\n\\n' & $states.input.message) %}"
        }
        Catch = [{
          ErrorEquals = ["States.ALL"]
          Next        = "PublishToSNS"
          Assign = {
            subject         = "{% $states.input.subject %}"
            enrichedMessage = "{% '■ Responsible Person\\nContact: Unavailable\\n\\n' & $states.input.message %}"
          }
        }]
        Next = "PublishToSNS"
      }
      PublishToSNS = {
        Type     = "Task"
        Resource = "arn:aws:states:::sns:publish"
        Arguments = {
          TopicArn = aws_sns_topic.security_alert.arn
          Subject  = "{% $subject %}"
          Message  = "{% $enrichedMessage %}"
        }
        End = true
      }
    }
  })
}

resource "aws_iam_role_policy" "sfn_org_read" {
  name = "org-list-tags"
  role = aws_iam_role.sfn.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "organizations:ListTagsForResource"
      Resource = "*"
    }]
  })
}

Since you can design it freely, you can include any information you like such as responsible person name, contact, project name, etc., but the slight downside is that you need to incorporate account tagging into your operational workflow.

How to Choose Among the 3 Patterns

Summarizing the 3 patterns:

Perspective Pattern 1: Registration Email Pattern 2: Alternate Contact Pattern 3: Tags
API Used DescribeAccount GetAlternateContact ListTagsForResource
Preparation None required Set contact per account Tag each account
Information Registration email · Account name Name · Email · Phone · Title Free design
When not set Does not occur Exception (fallback needed) No tags (fallback needed)
Best for Want to get started quickly Want to include appropriate contact Want custom responsible person info or custom values

Common Considerations

Points to be careful about that are common to all patterns:

Don't Stop Notifications Even if Information Retrieval Fails

Cases where information retrieval fails will inevitably occur — accounts without alternate contacts or tags set, or insufficient permissions.
Responsible person information is ultimately "supplementary information that is convenient to have," so it would be putting the cart before the horse if the security detection notification itself couldn't be sent just because this information couldn't be retrieved.

Therefore, in the examples in this blog, a Catch is included in each pattern's state machine definition to catch retrieval failures, set the responsible person field to "Unavailable," and continue with the notification.

Catch = [{
  ErrorEquals = ["States.ALL"]
  Next        = "PublishToSNS"
  Assign = {
    subject         = "{% $states.input.subject %}"
    enrichedMessage = "{% '■ Responsible Person\\nContact: Unavailable\\n\\n' & $states.input.message %}"
  }
}]

Regarding Personal Information in Notifications

If you include an individual's name or email address in the alternate contact or tags, that information will be included in the email body.
However, considering factors like responsible person changes, it's generally preferable to use team names or mailing lists rather than individual names.
(That's also the intent behind using team names in the samples in this article.)

Conclusion

That wraps up how to nicely insert responsible person information and more into the body of security service detection emails.

For getting started quickly, the registration email is recommended; if you want to specify the contact of a specific responsible person, alternate contacts; and if you want a free design, tags — choosing based on your use case is recommended.
Also, since all patterns can be implemented without Lambda using Step Functions' AWS SDK integration and JSONata, the operational overhead of this mechanism itself is relatively low, which is another recommended point.

I hope this is helpful for those who are struggling with managing security notifications in a multi-account environment.

Share this article

AWSのお困り事はクラスメソッドへ