IaC AWS

Terraform Module: AWS GuardDuty — threat detection wired up in one apply

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for AWS GuardDuty: enable the detector, opt into Malware Protection, S3, EKS, RDS and Runtime Monitoring features, and export the detector ID for downstream automation. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "guardduty" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-guardduty?ref=v1.0.0"

  # (no required inputs — all have sensible defaults)
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon GuardDuty is AWS’s managed threat-detection service. It continuously ingests CloudTrail management and S3 data events, VPC Flow Logs, and DNS query logs, then runs them through threat intelligence and machine-learning models to surface findings like crypto-mining EC2 instances, credential exfiltration, anonymising-proxy access, and S3 buckets suddenly made public. There is nothing to deploy on your hosts for the baseline — you enable a single regional detector and GuardDuty starts analysing log sources it already has access to.

The catch is that GuardDuty is per-region and per-account, and the interesting protection lives behind opt-in feature blocks: S3 Protection, EKS Audit Logs, EKS Runtime Monitoring, RDS Login Activity, EBS Malware Protection, and Lambda Network Logs. Click-ops across dozens of accounts and regions drifts almost immediately — one region gets Malware Protection, another forgets RDS login monitoring, and your finding coverage becomes a guessing game. Wrapping aws_guardduty_detector (plus aws_guardduty_detector_feature and aws_guardduty_malware_protection_plan) in a small, var-driven module makes “GuardDuty is on, with exactly these features, at this finding-publishing cadence” a reviewable, repeatable unit you stamp into every account-region with identical settings.

When to use it

If you only need GuardDuty in one account in one region forever, the raw resource is fine. The module pays off the moment you have more than one place to keep consistent.

Module structure

terraform-module-aws-guardduty/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # GuardDuty allows the detector itself to enable Runtime Monitoring inline,
  # but AWS recommends managing each feature via the dedicated resource so that
  # additional configuration (e.g. EKS add-on management) can be expressed.
  feature_set = {
    for f in var.features : f.name => f
  }

  malware_protection_enabled = var.malware_protection_scan_ec2
}

resource "aws_guardduty_detector" "this" {
  enable                       = var.enable
  finding_publishing_frequency = var.finding_publishing_frequency

  tags = merge(
    var.tags,
    {
      Name = var.detector_name
    },
  )
}

# Opt-in protection features (S3, EKS audit/runtime, RDS login, Lambda network,
# EBS malware). Each is its own resource so environments can flip them
# independently without touching the detector.
resource "aws_guardduty_detector_feature" "this" {
  for_each = local.feature_set

  detector_id = aws_guardduty_detector.this.id
  name        = each.value.name
  status      = each.value.status

  # Sub-feature toggles, e.g. EKS_ADDON_MANAGEMENT under RUNTIME_MONITORING.
  dynamic "additional_configuration" {
    for_each = each.value.additional_configuration
    content {
      name   = additional_configuration.value.name
      status = additional_configuration.value.status
    }
  }
}

# EBS volume malware scanning triggered by suspicious findings, with optional
# tagging of the snapshots GuardDuty creates during a scan.
resource "aws_guardduty_malware_protection_plan" "ec2" {
  count = local.malware_protection_enabled ? 1 : 0

  role = var.malware_protection_role_arn

  protected_resource {
    instance {
      tags {
        key   = var.malware_protection_tag_key
        value = var.malware_protection_tag_value
      }
    }
  }

  actions {
    tagging {
      status = var.malware_protection_tag_snapshots ? "ENABLED" : "DISABLED"
    }
  }

  tags = var.tags
}

variables.tf

variable "enable" {
  description = "Whether the GuardDuty detector is enabled. Set to false to suspend analysis without destroying the detector and its history."
  type        = bool
  default     = true
}

variable "detector_name" {
  description = "Logical name applied as the Name tag on the detector (the detector resource itself has no name attribute in AWS)."
  type        = string
  default     = "guardduty"
}

variable "finding_publishing_frequency" {
  description = "Cadence at which updated findings are exported to EventBridge / S3 / Security Hub. One of FIFTEEN_MINUTES, ONE_HOUR, SIX_HOURS."
  type        = string
  default     = "SIX_HOURS"

  validation {
    condition     = contains(["FIFTEEN_MINUTES", "ONE_HOUR", "SIX_HOURS"], var.finding_publishing_frequency)
    error_message = "finding_publishing_frequency must be one of FIFTEEN_MINUTES, ONE_HOUR, or SIX_HOURS."
  }
}

variable "features" {
  description = <<-EOT
    List of GuardDuty detector features to manage. Each item: name (one of
    S3_DATA_EVENTS, EKS_AUDIT_LOGS, EBS_MALWARE_PROTECTION, RDS_LOGIN_EVENTS,
    LAMBDA_NETWORK_LOGS, RUNTIME_MONITORING), status (ENABLED|DISABLED), and an
    optional additional_configuration list of sub-feature toggles such as
    EKS_ADDON_MANAGEMENT, ECS_FARGATE_AGENT_MANAGEMENT, EC2_AGENT_MANAGEMENT.
  EOT
  type = list(object({
    name   = string
    status = string
    additional_configuration = optional(list(object({
      name   = string
      status = string
    })), [])
  }))
  default = [
    { name = "S3_DATA_EVENTS", status = "ENABLED" },
    { name = "RDS_LOGIN_EVENTS", status = "ENABLED" },
    { name = "LAMBDA_NETWORK_LOGS", status = "ENABLED" },
    { name = "EKS_AUDIT_LOGS", status = "ENABLED" },
  ]

  validation {
    condition = alltrue([
      for f in var.features : contains([
        "S3_DATA_EVENTS", "EKS_AUDIT_LOGS", "EBS_MALWARE_PROTECTION",
        "RDS_LOGIN_EVENTS", "LAMBDA_NETWORK_LOGS", "RUNTIME_MONITORING",
      ], f.name)
    ])
    error_message = "Each feature name must be a valid GuardDuty detector feature (e.g. S3_DATA_EVENTS, RUNTIME_MONITORING, EBS_MALWARE_PROTECTION)."
  }

  validation {
    condition     = alltrue([for f in var.features : contains(["ENABLED", "DISABLED"], f.status)])
    error_message = "Each feature status must be ENABLED or DISABLED."
  }
}

variable "malware_protection_scan_ec2" {
  description = "Create a GuardDuty Malware Protection plan that scans EBS volumes of matching EC2 instances. Requires malware_protection_role_arn."
  type        = bool
  default     = false
}

variable "malware_protection_role_arn" {
  description = "IAM role ARN GuardDuty assumes to perform agentless EBS malware scans. Required when malware_protection_scan_ec2 is true."
  type        = string
  default     = null

  validation {
    condition     = var.malware_protection_role_arn == null || can(regex("^arn:aws[a-zA-Z-]*:iam::[0-9]{12}:role/.+$", var.malware_protection_role_arn))
    error_message = "malware_protection_role_arn must be a valid IAM role ARN."
  }
}

variable "malware_protection_tag_key" {
  description = "Tag key used to select which EC2 instances the Malware Protection plan scans."
  type        = string
  default     = "GuardDutyMalwareScan"
}

variable "malware_protection_tag_value" {
  description = "Tag value (paired with malware_protection_tag_key) selecting EC2 instances to scan."
  type        = string
  default     = "true"
}

variable "malware_protection_tag_snapshots" {
  description = "Whether GuardDuty tags the snapshots it creates during a malware scan, so they are identifiable and lifecycle-managed."
  type        = bool
  default     = true
}

variable "tags" {
  description = "Tags applied to the detector and Malware Protection plan."
  type        = map(string)
  default     = {}
}

outputs.tf

output "detector_id" {
  description = "The ID of the GuardDuty detector. Use this to attach EventBridge rules, member accounts, or filters downstream."
  value       = aws_guardduty_detector.this.id
}

output "detector_arn" {
  description = "The ARN of the GuardDuty detector."
  value       = aws_guardduty_detector.this.arn
}

output "account_id" {
  description = "The AWS account ID that owns the detector."
  value       = aws_guardduty_detector.this.account_id
}

output "enabled_features" {
  description = "Map of managed feature name to its configured status."
  value       = { for k, v in aws_guardduty_detector_feature.this : k => v.status }
}

output "malware_protection_plan_id" {
  description = "ID of the EBS Malware Protection plan, or null when malware scanning is disabled."
  value       = try(aws_guardduty_malware_protection_plan.ec2[0].id, null)
}

How to use it

module "guardduty" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-guardduty?ref=v1.0.0"

  enable                       = true
  detector_name                = "prod-use1-guardduty"
  finding_publishing_frequency = "FIFTEEN_MINUTES"

  features = [
    { name = "S3_DATA_EVENTS", status = "ENABLED" },
    { name = "RDS_LOGIN_EVENTS", status = "ENABLED" },
    { name = "LAMBDA_NETWORK_LOGS", status = "ENABLED" },
    { name = "EKS_AUDIT_LOGS", status = "ENABLED" },
    {
      name   = "RUNTIME_MONITORING"
      status = "ENABLED"
      additional_configuration = [
        { name = "EKS_ADDON_MANAGEMENT", status = "ENABLED" },
        { name = "ECS_FARGATE_AGENT_MANAGEMENT", status = "ENABLED" },
        { name = "EC2_AGENT_MANAGEMENT", status = "ENABLED" },
      ]
    },
  ]

  malware_protection_scan_ec2 = true
  malware_protection_role_arn = aws_iam_role.guardduty_malware.arn

  tags = {
    Environment = "prod"
    ManagedBy   = "terraform"
    CostCenter  = "security"
  }
}

# Downstream: route every GuardDuty finding for this detector to a response
# Lambda via EventBridge, keyed off the module's detector_id output.
resource "aws_cloudwatch_event_rule" "guardduty_findings" {
  name        = "prod-use1-guardduty-findings"
  description = "Forward GuardDuty findings to the security response pipeline"

  event_pattern = jsonencode({
    source      = ["aws.guardduty"]
    detail-type = ["GuardDuty Finding"]
    detail = {
      service = {
        detectorId = [module.guardduty.detector_id]
      }
    }
  })
}

resource "aws_cloudwatch_event_target" "to_responder" {
  rule = aws_cloudwatch_event_rule.guardduty_findings.name
  arn  = aws_lambda_function.finding_responder.arn
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/guardduty/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-guardduty?ref=v1.0.0"
}

inputs = {
  # (no required inputs)
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/guardduty && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
enable bool true no Whether the detector is enabled; false suspends analysis without losing history.
detector_name string "guardduty" no Logical name applied as the Name tag on the detector.
finding_publishing_frequency string "SIX_HOURS" no Export cadence for updated findings: FIFTEEN_MINUTES, ONE_HOUR, or SIX_HOURS.
features list(object) S3, RDS, Lambda, EKS audit enabled no Detector features to manage, each with name, status, and optional additional_configuration sub-toggles.
malware_protection_scan_ec2 bool false no Create an EBS Malware Protection plan for matching EC2 instances.
malware_protection_role_arn string null conditional IAM role GuardDuty assumes for agentless EBS scans; required when malware_protection_scan_ec2 = true.
malware_protection_tag_key string "GuardDutyMalwareScan" no Tag key selecting which EC2 instances are scanned.
malware_protection_tag_value string "true" no Tag value paired with the key to select instances.
malware_protection_tag_snapshots bool true no Whether GuardDuty tags snapshots it creates during a scan.
tags map(string) {} no Tags applied to the detector and Malware Protection plan.

Outputs

Name Description
detector_id ID of the GuardDuty detector; reference it for EventBridge rules, members, and filters.
detector_arn ARN of the GuardDuty detector.
account_id AWS account ID that owns the detector.
enabled_features Map of managed feature name to its configured status.
malware_protection_plan_id ID of the EBS Malware Protection plan, or null when disabled.

Enterprise scenario

A fintech running a 60-account AWS Organization calls this module from its account-baseline pipeline, looped over every enabled region with a for_each on the provider aliases. Production OUs pass the full feature set — RUNTIME_MONITORING with EKS, Fargate and EC2 agent management plus EBS Malware Protection — while sandbox accounts get only S3_DATA_EVENTS and RDS_LOGIN_EVENTS to keep spend down. The delegated administrator account auto-enrolls each new member, and the exported detector_id from every invocation feeds a centralized EventBridge bus that fans findings into Security Hub and a PagerDuty escalation Lambda, so a single Terraform change rolls consistent threat detection across the whole estate.

Best practices

TerraformAWSGuardDutyModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading