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
- You run a multi-account AWS Organization and want GuardDuty enabled identically in every member account and every active region via a baseline / landing-zone pipeline.
- You need provable, drift-free coverage of which protection features are on (auditors and the CIS AWS Benchmark both ask “is GuardDuty enabled?”).
- You want to toggle expensive features (EKS Runtime Monitoring, Malware Protection) per environment — full coverage in prod, a leaner set in sandbox — without hand-editing the console.
- You are wiring GuardDuty findings into EventBridge, Security Hub, or an automated response Lambda and need the detector ID as a stable Terraform output to reference downstream.
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 config — live/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 config — live/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
- Set the right publishing frequency. GuardDuty defaults new detectors to
SIX_HOURS; drop production toFIFTEEN_MINUTESso findings reach your response automation fast, but leave low-value sandbox accounts atSIX_HOURSsince the cadence has no cost impact and reduces alert noise. - Enable, don’t destroy, when pausing. Toggle
enable = falseto suspend analysis — runningterraform destroyon a detector discards its finding history and resets the free-trial usage baseline, which you rarely want. - Gate the expensive features per environment.
RUNTIME_MONITORING(EKS/ECS/EC2 agents) andEBS_MALWARE_PROTECTIONare the largest line items; drive them through thefeatureslist andmalware_protection_scan_ec2so prod gets full coverage while non-prod stays lean. Scope malware scans withmalware_protection_tag_key/_valuerather than scanning every instance. - Use the delegated administrator, not per-account detectors, for org-wide rollout. Enable GuardDuty from a dedicated Security/Audit account as the org delegated admin so member detectors and findings aggregate centrally; this module deploys the per-account detector that admin then governs.
- Name and tag for region clarity. GuardDuty is regional, so bake the account and region into
detector_name(e.g.prod-use1-guardduty) and tag withEnvironmentandCostCenter— it makes multi-region Security Hub triage and cost attribution far easier. - Wire findings to action. A detector with no consumer is just a dashboard. Always route
detector_idinto EventBridge → Security Hub and an automated responder so high-severity findings (crypto-mining, credential exfiltration, public-S3) trigger containment instead of waiting for a human to notice.