From c82eab68ba55cec7c09f742f06e63e7a808ec96c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:28:49 +0000 Subject: [PATCH 1/5] Add default multi-Region CloudTrail compliance audit logging Co-Authored-By: brandon --- README.md | 2 +- security/compliance/README.md | 17 ++- security/compliance/cloudtrail.tf | 29 ++++ security/compliance/cloudtrail_bucket.tf | 106 +++++++++++++++ security/compliance/data.tf | 4 + security/compliance/outputs.tf | 15 +++ .../rvn-aws-compliance-definition.yml | 53 +++++++- .../compliance/tests/cloudtrail.tftest.hcl | 127 ++++++++++++++++++ security/compliance/variables.tf | 32 ++++- 9 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 security/compliance/cloudtrail.tf create mode 100644 security/compliance/cloudtrail_bucket.tf create mode 100644 security/compliance/tests/cloudtrail.tftest.hcl diff --git a/README.md b/README.md index ebbd5de9..0fac6d9f 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ sync by `node tools/ravion-modules/dist/src/cli.js readme` (enforced in CI, and | `rvn-aws-alb` | AWS Application Load Balancer | v1.3.0 | `networking/alb/` | | `rvn-aws-alternat` | alterNAT | v0.1.2 | `networking/alternat/` | | `rvn-aws-ami` | AMI | v0.3.1 | `compute/ami/` | -| `rvn-aws-compliance` | AWS Compliance | v0.2.0 | `security/compliance/` | +| `rvn-aws-compliance` | AWS Compliance | v0.3.0 | `security/compliance/` | | `rvn-aws-iam-policy` | AWS IAM Policy | v1.1.1 | `security/iam_policy/` | | `rvn-aws-iam-role` | AWS IAM Role | v1.1.1 | `security/iam/` | | `rvn-aws-kms` | AWS KMS Key | v0.2.0 | `security/kms/` | diff --git a/security/compliance/README.md b/security/compliance/README.md index e68699ff..0b61a98e 100644 --- a/security/compliance/README.md +++ b/security/compliance/README.md @@ -1,10 +1,11 @@ # AWS compliance module -Manages GuardDuty threat detection and registry-wide Basic ECR image scanning on push for one AWS account across a shared list of Regions. Deploy `rvn-aws-compliance` once per account with all required Regions. These settings support threat-detection and vulnerability-scanning controls; application alarms, log retention, and other compliance controls remain in their respective modules. +Manages GuardDuty threat detection, registry-wide Basic ECR image scanning on push, and a multi-Region CloudTrail audit trail for one AWS account. Deploy `rvn-aws-compliance` once per account with all required Regions. ## Features - Registry-wide ECR Basic `SCAN_ON_PUSH` with a `*` filter for all current and future private repositories, including Lambda image repositories. +- One validated multi-Region CloudTrail management-event trail with private, versioned log storage in the runner Region. - One `aws_guardduty_detector` per Region, driven by the AWS provider's per-resource `region` argument (no provider alias per Region). - Fails at plan time when a requested Region is not enabled (opted in) for the account. - Explicit per-Region protection plans: S3 Protection, EKS Protection, Malware Protection for EC2, RDS Protection, Lambda Protection, and Runtime Monitoring with automated agent management for EKS, ECS Fargate, and EC2. @@ -67,9 +68,20 @@ The AWS provider 6.x per-resource `region` argument is required. The credentials ## Inputs +CloudTrail is enabled by default: one multi-Region trail in the provider's runner Region records read/write management events and global service events, with log file validation and no data events. Its dedicated private S3 bucket has versioning, bucket-owner-enforced ownership and SSE-S3 encryption by default. + +The first copy of management events is free; an existing trail makes this a paid second copy. Disable CloudTrail when an organization trail already covers the account. S3 storage and optional KMS usage incur charges. + +Current and noncurrent versions expire after the configured retention period (365 days by default); 0 disables expiry. Current-object expiry creates a delete marker before noncurrent expiry applies. Destroy, including disabling CloudTrail, fails while logs or versions remain in the bucket because force deletion is disabled. + +A supplied KMS key encrypts trail deliveries and bucket objects. Its key policy must allow cloudtrail.amazonaws.com; this module does not manage that policy. + | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | `regions` | AWS Regions where GuardDuty and ECR scan-on-push are enabled. Each must be enabled for the account. | `list(string)` | n/a | yes | +| `cloudtrail_enabled` | Create one multi-Region management-event trail. | `bool` | `true` | no | +| `cloudtrail_log_retention_days` | Current and noncurrent log expiry; 0 keeps logs forever. | `number` | `365` | no | +| `cloudtrail_kms_key_arn` | Optional key whose policy permits CloudTrail encryption; null uses SSE-S3. | `string` | `null` | no | | `finding_publishing_frequency` | Export cadence for updated findings: `FIFTEEN_MINUTES`, `ONE_HOUR`, or `SIX_HOURS`. | `string` | `"FIFTEEN_MINUTES"` | no | | `s3_protection_enabled` | Enable S3 Protection (`S3_DATA_EVENTS`). | `bool` | `true` | no | | `eks_protection_enabled` | Enable EKS Protection (`EKS_AUDIT_LOGS`). | `bool` | `true` | no | @@ -85,6 +97,9 @@ The AWS provider 6.x per-resource `region` argument is required. The credentials | Name | Description | | ---- | ----------- | | `regions` | Sorted list of Regions covered by GuardDuty and ECR scanning. | +| `cloudtrail_arn` | Trail ARN, or null when disabled. | +| `cloudtrail_bucket_name` | Audit log bucket name, or null when disabled. | +| `cloudtrail_bucket_arn` | Audit log bucket ARN, or null when disabled. | | `detector_ids` | Map of Region to detector ID. | | `detector_arns` | Map of Region to detector ARN. | | `account_id` | AWS account ID where the baseline is managed. | diff --git a/security/compliance/cloudtrail.tf b/security/compliance/cloudtrail.tf new file mode 100644 index 00000000..cc689936 --- /dev/null +++ b/security/compliance/cloudtrail.tf @@ -0,0 +1,29 @@ +locals { + cloudtrail_name = "ravion-compliance" + cloudtrail_arn = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}" +} + +resource "aws_cloudtrail" "this" { + count = var.cloudtrail_enabled ? 1 : 0 + + name = local.cloudtrail_name + s3_bucket_name = aws_s3_bucket.cloudtrail[0].id + is_multi_region_trail = true + include_global_service_events = true + enable_log_file_validation = true + enable_logging = true + kms_key_id = var.cloudtrail_kms_key_arn + + event_selector { + read_write_type = "All" + include_management_events = true + } + + tags = local.tags + + depends_on = [ + aws_s3_bucket_policy.cloudtrail, + aws_s3_bucket_server_side_encryption_configuration.cloudtrail, + aws_s3_bucket_versioning.cloudtrail, + ] +} diff --git a/security/compliance/cloudtrail_bucket.tf b/security/compliance/cloudtrail_bucket.tf new file mode 100644 index 00000000..810cc3ff --- /dev/null +++ b/security/compliance/cloudtrail_bucket.tf @@ -0,0 +1,106 @@ +resource "aws_s3_bucket" "cloudtrail" { + count = var.cloudtrail_enabled ? 1 : 0 + + bucket_prefix = "ravion-cloudtrail-${data.aws_caller_identity.current.account_id}-" + force_destroy = false + tags = local.tags +} + +resource "aws_s3_bucket_public_access_block" "cloudtrail" { + count = var.cloudtrail_enabled ? 1 : 0 + + bucket = aws_s3_bucket.cloudtrail[0].id + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "cloudtrail" { + count = var.cloudtrail_enabled ? 1 : 0 + + bucket = aws_s3_bucket.cloudtrail[0].id + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "cloudtrail" { + count = var.cloudtrail_enabled ? 1 : 0 + + bucket = aws_s3_bucket.cloudtrail[0].id + rule { + apply_server_side_encryption_by_default { + sse_algorithm = var.cloudtrail_kms_key_arn == null ? "AES256" : "aws:kms" + kms_master_key_id = var.cloudtrail_kms_key_arn + } + } +} + +resource "aws_s3_bucket_versioning" "cloudtrail" { + count = var.cloudtrail_enabled ? 1 : 0 + + bucket = aws_s3_bucket.cloudtrail[0].id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "cloudtrail" { + count = var.cloudtrail_enabled && var.cloudtrail_log_retention_days > 0 ? 1 : 0 + + bucket = aws_s3_bucket.cloudtrail[0].id + rule { + id = "audit-log-retention" + status = "Enabled" + filter {} + expiration { + days = var.cloudtrail_log_retention_days + } + noncurrent_version_expiration { + noncurrent_days = var.cloudtrail_log_retention_days + } + } + depends_on = [aws_s3_bucket_versioning.cloudtrail] +} + +resource "aws_s3_bucket_policy" "cloudtrail" { + count = var.cloudtrail_enabled ? 1 : 0 + + bucket = aws_s3_bucket.cloudtrail[0].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "CloudTrailAclCheck" + Effect = "Allow" + Principal = { Service = "cloudtrail.amazonaws.com" } + Action = "s3:GetBucketAcl" + Resource = aws_s3_bucket.cloudtrail[0].arn + Condition = { StringEquals = { "aws:SourceArn" = local.cloudtrail_arn } } + }, + { + Sid = "CloudTrailWrite" + Effect = "Allow" + Principal = { Service = "cloudtrail.amazonaws.com" } + Action = "s3:PutObject" + Resource = "${aws_s3_bucket.cloudtrail[0].arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*" + Condition = { + StringEquals = { + "aws:SourceArn" = local.cloudtrail_arn + "s3:x-amz-acl" = "bucket-owner-full-control" + } + } + }, + { + Sid = "DenyInsecureTransport" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [aws_s3_bucket.cloudtrail[0].arn, "${aws_s3_bucket.cloudtrail[0].arn}/*"] + Condition = { Bool = { "aws:SecureTransport" = "false" } } + }, + ] + }) + depends_on = [aws_s3_bucket_public_access_block.cloudtrail, aws_s3_bucket_ownership_controls.cloudtrail] +} diff --git a/security/compliance/data.tf b/security/compliance/data.tf index 71da9338..6c007055 100644 --- a/security/compliance/data.tf +++ b/security/compliance/data.tf @@ -1,5 +1,9 @@ data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +data "aws_partition" "current" {} + # Both services require the selected Regions to be enabled for the account. data "aws_regions" "enabled" { all_regions = false diff --git a/security/compliance/outputs.tf b/security/compliance/outputs.tf index c36a3baa..73ce20e0 100644 --- a/security/compliance/outputs.tf +++ b/security/compliance/outputs.tf @@ -7,6 +7,21 @@ output "regions" { value = sort(var.regions) } +output "cloudtrail_arn" { + description = "Multi-Region CloudTrail trail ARN, or null when disabled." + value = var.cloudtrail_enabled ? aws_cloudtrail.this[0].arn : null +} + +output "cloudtrail_bucket_name" { + description = "CloudTrail audit log bucket name, or null when disabled." + value = var.cloudtrail_enabled ? aws_s3_bucket.cloudtrail[0].id : null +} + +output "cloudtrail_bucket_arn" { + description = "CloudTrail audit log bucket ARN, or null when disabled." + value = var.cloudtrail_enabled ? aws_s3_bucket.cloudtrail[0].arn : null +} + output "detector_ids" { description = "Map of Region to GuardDuty detector ID." value = { for region, detector in aws_guardduty_detector.this : region => detector.id } diff --git a/security/compliance/rvn-aws-compliance-definition.yml b/security/compliance/rvn-aws-compliance-definition.yml index 017175ed..e0bd5f00 100644 --- a/security/compliance/rvn-aws-compliance-definition.yml +++ b/security/compliance/rvn-aws-compliance-definition.yml @@ -1,10 +1,10 @@ definition: type: rvn-aws-compliance name: AWS Compliance - description: Manages an AWS compliance baseline with GuardDuty threat detection and registry-wide ECR image scanning across selected Regions. + description: Manages an AWS compliance baseline with GuardDuty, ECR image scanning, and a multi-Region CloudTrail audit trail. release: - version: 0.2.0 - description: Add Terraform plan and apply IAM policy inputs to scope stack runner permissions. + version: 0.3.0 + description: Record management events across Regions with a private, versioned CloudTrail log bucket and configurable retention. module: inputs: - $include: ../../partials/inputs/aws-account.yml @@ -21,6 +21,29 @@ module: required: true type: string_array values: $values:aws/regions + - id: section_cloudtrail + label: CloudTrail + type: section + - id: cloudtrail_enabled + label: CloudTrail + type: boolean + default: true + description: Turn off if an organization trail already records management events. + - id: cloudtrail_log_retention_days + label: Log retention (days) + type: number + default: 365 + min: 0 + description: S3 lifecycle expiry; 0 keeps logs forever. + show_when: + cloudtrail_enabled: true + - id: cloudtrail_kms_key_arn + label: KMS key ARN + type: string + required: false + description: Leave blank for SSE-S3. The key policy must allow cloudtrail.amazonaws.com. + show_when: + cloudtrail_enabled: true - id: section_protection_plans label: GuardDuty protection plans type: section @@ -96,6 +119,9 @@ module: base_path: security/compliance terraform_variables: ...overrides: << module.input.advanced_terraform_variables >> + cloudtrail_enabled: << module.input.cloudtrail_enabled >> + cloudtrail_log_retention_days: "<< module.input.cloudtrail_log_retention_days != nil ? module.input.cloudtrail_log_retention_days : 365 >>" + cloudtrail_kms_key_arn: << module.input.cloudtrail_kms_key_arn || nil >> eks_protection_enabled: << module.input.eks_protection_enabled >> finding_publishing_frequency: << module.input.finding_publishing_frequency || "FIFTEEN_MINUTES" >> lambda_protection_enabled: << module.input.lambda_protection_enabled >> @@ -116,13 +142,13 @@ module: }) : [] >> readme: | - Manages an AWS compliance baseline with GuardDuty threat detection and Basic ECR image scanning on push across selected Regions. + Manages an AWS compliance baseline with GuardDuty, ECR image scanning, and a multi-Region CloudTrail audit trail. ## Account-level setup - Deploy one module for an AWS account and select every Region you want covered. The runner Region controls where Terraform executes; the Regions list controls where both services are configured. Every selected Region must already be enabled for the account. + Deploy one module for an AWS account and select every Region you want covered. The runner Region controls where Terraform executes and hosts the CloudTrail trail and bucket; the Regions list controls GuardDuty and ECR scanning. Every selected Region must already be enabled for the account. - Each Region receives a GuardDuty detector with explicitly managed protection plans and an ECR registry scanning rule covering all existing and future private repositories. The module supports threat-detection and vulnerability-scanning controls; application alarms, log retention, and other compliance controls remain in their respective modules. + Each selected Region receives a GuardDuty detector with explicitly managed protection plans and an ECR registry scanning rule covering all existing and future private repositories. CloudTrail records management events across Regions; application alarms and application log retention remain in their respective modules. ## GuardDuty @@ -138,6 +164,16 @@ module: ## Existing resources and ownership + CloudTrail is enabled by default and creates exactly one multi-Region trail in the runner Region. It records read and write management events, including global service events, with log file validation. No data events are recorded. + + Logs go to a dedicated private S3 bucket with public access blocked, bucket-owner-enforced ownership, versioning, and encryption. Log retention (days) sets lifecycle expiry for current and noncurrent object versions; 365 is the default and 0 keeps them forever. With versioning, current-object expiry makes a version noncurrent, then noncurrent expiry applies. + + Leave KMS key ARN blank for SSE-S3, or supply a key whose policy allows cloudtrail.amazonaws.com to encrypt logs. This module does not manage the key policy. + + The first copy of management events is free; S3 storage and optional KMS usage incur charges. If an existing trail already records these events, this trail is a paid second copy. Turn CloudTrail off if an organization trail already provides coverage. + + Destroy fails while the bucket contains logs or object versions: force deletion is disabled. Disabling CloudTrail also attempts to remove its bucket, so preserve or explicitly remove retained logs before doing so. + GuardDuty detectors and ECR scanning configurations are shared by the whole account in each Region. Keep exactly one Terraform owner per account and Region; do not deploy overlapping instances or also manage them from a VPC or service module. If GuardDuty is already enabled, import the existing detectors into this module's Terraform state before applying. Do not disable GuardDuty just to adopt this module. Existing protection-plan settings are reconciled to the selected inputs. @@ -154,11 +190,14 @@ module: | Region | Required | Terraform runner Region. | | Regions | Required | Regions covered by both GuardDuty and ECR scanning. | | ECR scanning | Basic scan-on-push for all repositories | Always enabled by this module. | + | CloudTrail | Enabled | One multi-Region management-event trail in the runner Region. | + | Log retention (days) | 365 | S3 lifecycle expiry; 0 keeps logs forever. | + | KMS key ARN | None (SSE-S3) | Optional key whose policy permits CloudTrail encryption. | | S3, EKS, malware, RDS, Lambda protection | Enabled | GuardDuty protection plans. | | Runtime monitoring | Disabled | Optional workload security agents. | | Automated agent management | EKS, ECS Fargate, EC2 | Used when Runtime Monitoring is enabled. | | Finding publishing frequency | Every 15 minutes | Export cadence for updated GuardDuty findings. | - | Tags | Standard Ravion tags | Additional tags for GuardDuty detectors; ECR registry scanning does not support tags. | + | Tags | Standard Ravion tags | Additional tags for GuardDuty, CloudTrail and its bucket; ECR registry scanning does not support tags. | ## Learn more diff --git a/security/compliance/tests/cloudtrail.tftest.hcl b/security/compliance/tests/cloudtrail.tftest.hcl new file mode 100644 index 00000000..99c42e26 --- /dev/null +++ b/security/compliance/tests/cloudtrail.tftest.hcl @@ -0,0 +1,127 @@ +mock_provider "aws" { + mock_data "aws_caller_identity" { + defaults = { account_id = "123456789012" } + } + mock_data "aws_region" { + defaults = { region = "us-east-2" } + } + mock_data "aws_partition" { + defaults = { partition = "aws" } + } + mock_data "aws_regions" { + defaults = { names = ["us-east-1", "eu-west-1"] } + } + mock_resource "aws_s3_bucket" { + defaults = { + id = "ravion-cloudtrail-123456789012-test" + arn = "arn:aws:s3:::ravion-cloudtrail-123456789012-test" + } + } +} + +variables { + regions = ["us-east-1", "eu-west-1"] +} + +run "default_multi_region_trail" { + command = plan + + assert { + condition = length(aws_cloudtrail.this) == 1 && aws_cloudtrail.this[0].is_multi_region_trail && aws_cloudtrail.this[0].include_global_service_events && aws_cloudtrail.this[0].enable_log_file_validation && aws_cloudtrail.this[0].enable_logging + error_message = "Exactly one validated multi-Region trail must record events by default." + } + assert { + condition = one(aws_cloudtrail.this[0].event_selector).read_write_type == "All" && one(aws_cloudtrail.this[0].event_selector).include_management_events && length(one(aws_cloudtrail.this[0].event_selector).data_resource) == 0 + error_message = "Only read and write management events must be selected." + } + assert { + condition = local.cloudtrail_arn == "arn:aws:cloudtrail:us-east-2:123456789012:trail/ravion-compliance" + error_message = "The trail must use the runner Region, not the selected compliance Regions." + } + assert { + condition = !aws_s3_bucket.cloudtrail[0].force_destroy && aws_s3_bucket.cloudtrail[0].bucket_prefix == "ravion-cloudtrail-123456789012-" && aws_s3_bucket.cloudtrail[0].tags["Module"] == "security/compliance" + error_message = "The bucket must use a unique account-prefixed name, standard tags and safe deletion." + } + assert { + condition = aws_s3_bucket_public_access_block.cloudtrail[0].block_public_acls && aws_s3_bucket_public_access_block.cloudtrail[0].block_public_policy && aws_s3_bucket_public_access_block.cloudtrail[0].ignore_public_acls && aws_s3_bucket_public_access_block.cloudtrail[0].restrict_public_buckets && one(aws_s3_bucket_ownership_controls.cloudtrail[0].rule).object_ownership == "BucketOwnerEnforced" + error_message = "Audit logs must remain private and bucket-owner enforced." + } + assert { + condition = one(aws_s3_bucket_versioning.cloudtrail[0].versioning_configuration).status == "Enabled" && one(one(aws_s3_bucket_server_side_encryption_configuration.cloudtrail[0].rule).apply_server_side_encryption_by_default).sse_algorithm == "AES256" && aws_cloudtrail.this[0].kms_key_id == null + error_message = "Versioning and SSE-S3 must be enabled by default." + } + assert { + condition = one(one(aws_s3_bucket_lifecycle_configuration.cloudtrail[0].rule).expiration).days == 365 && one(one(aws_s3_bucket_lifecycle_configuration.cloudtrail[0].rule).noncurrent_version_expiration).noncurrent_days == 365 + error_message = "Current and noncurrent logs must expire after the configured period." + } + assert { + condition = jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[0].Condition.StringEquals["aws:SourceArn"] == local.cloudtrail_arn && jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[1].Condition.StringEquals["aws:SourceArn"] == local.cloudtrail_arn && jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[1].Condition.StringEquals["s3:x-amz-acl"] == "bucket-owner-full-control" && jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[2].Condition.Bool["aws:SecureTransport"] == "false" + error_message = "Delivery must be scoped to this trail, require ownership ACLs and deny insecure transport." + } +} + +run "disabled_creates_no_cloudtrail_resources" { + command = plan + variables { + cloudtrail_enabled = false + } + assert { + condition = length(aws_cloudtrail.this) == 0 && length(aws_s3_bucket.cloudtrail) == 0 && length(aws_s3_bucket_policy.cloudtrail) == 0 && length(aws_s3_bucket_lifecycle_configuration.cloudtrail) == 0 && length(aws_s3_bucket_versioning.cloudtrail) == 0 && length(aws_s3_bucket_public_access_block.cloudtrail) == 0 && length(aws_s3_bucket_ownership_controls.cloudtrail) == 0 && length(aws_s3_bucket_server_side_encryption_configuration.cloudtrail) == 0 + error_message = "Disabling CloudTrail must create no trail or storage resources." + } + assert { + condition = output.cloudtrail_arn == null && output.cloudtrail_bucket_name == null && output.cloudtrail_bucket_arn == null + error_message = "CloudTrail outputs must be null when disabled." + } +} + +run "keep_forever" { + command = plan + variables { + cloudtrail_log_retention_days = 0 + } + assert { + condition = length(aws_s3_bucket_lifecycle_configuration.cloudtrail) == 0 && length(aws_s3_bucket.cloudtrail) == 1 + error_message = "Zero retention must disable expiry without disabling log storage." + } +} + +run "custom_retention_and_kms" { + command = plan + variables { + cloudtrail_log_retention_days = 90 + cloudtrail_kms_key_arn = "arn:aws:kms:us-east-2:123456789012:key/11111111-2222-3333-4444-555555555555" + } + assert { + condition = aws_cloudtrail.this[0].kms_key_id == var.cloudtrail_kms_key_arn && one(one(aws_s3_bucket_server_side_encryption_configuration.cloudtrail[0].rule).apply_server_side_encryption_by_default).kms_master_key_id == var.cloudtrail_kms_key_arn && one(one(aws_s3_bucket_server_side_encryption_configuration.cloudtrail[0].rule).apply_server_side_encryption_by_default).sse_algorithm == "aws:kms" + error_message = "The provided key must encrypt both trail deliveries and bucket objects." + } + assert { + condition = one(one(aws_s3_bucket_lifecycle_configuration.cloudtrail[0].rule).expiration).days == 90 && one(one(aws_s3_bucket_lifecycle_configuration.cloudtrail[0].rule).noncurrent_version_expiration).noncurrent_days == 90 + error_message = "Retention must apply to current and noncurrent versions." + } +} + +run "reject_negative_retention" { + command = plan + variables { + cloudtrail_log_retention_days = -1 + } + expect_failures = [var.cloudtrail_log_retention_days] +} + +run "reject_fractional_retention" { + command = plan + variables { + cloudtrail_log_retention_days = 1.5 + } + expect_failures = [var.cloudtrail_log_retention_days] +} + +run "reject_invalid_kms_key" { + command = plan + variables { + cloudtrail_kms_key_arn = "invalid" + } + expect_failures = [var.cloudtrail_kms_key_arn] +} diff --git a/security/compliance/variables.tf b/security/compliance/variables.tf index 1ad163ee..52eaad4b 100644 --- a/security/compliance/variables.tf +++ b/security/compliance/variables.tf @@ -2,6 +2,36 @@ # Regions ################################################################################ +variable "cloudtrail_enabled" { + type = bool + description = "Create a multi-Region management-event trail in the runner Region." + default = true + nullable = false +} + +variable "cloudtrail_log_retention_days" { + type = number + description = "S3 audit log expiry in days. Set to 0 to keep logs forever." + default = 365 + nullable = false + + validation { + condition = var.cloudtrail_log_retention_days >= 0 && floor(var.cloudtrail_log_retention_days) == var.cloudtrail_log_retention_days + error_message = "CloudTrail log retention must be a nonnegative whole number of days." + } +} + +variable "cloudtrail_kms_key_arn" { + type = string + description = "Optional KMS key ARN. Its policy must allow cloudtrail.amazonaws.com; null uses SSE-S3." + default = null + + validation { + condition = var.cloudtrail_kms_key_arn == null || can(regex("^arn:[^:]+:kms:[^:]+:[0-9]{12}:key/.+$", var.cloudtrail_kms_key_arn)) + error_message = "The CloudTrail KMS key must be a KMS key ARN." + } +} + variable "regions" { type = list(string) nullable = false @@ -107,6 +137,6 @@ variable "runtime_monitoring_automated_agents" { variable "tags" { type = map(string) - description = "A map of additional tags applied to every GuardDuty detector." + description = "A map of additional tags applied to GuardDuty detectors, the CloudTrail trail and its bucket." default = {} } From 11d1a1fc351fd9fcfcb870cf64ab8c84118cb9fa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:29:56 +0000 Subject: [PATCH 2/5] Give CloudTrail its own README section Co-Authored-By: brandon --- security/compliance/rvn-aws-compliance-definition.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/security/compliance/rvn-aws-compliance-definition.yml b/security/compliance/rvn-aws-compliance-definition.yml index e0bd5f00..c9839303 100644 --- a/security/compliance/rvn-aws-compliance-definition.yml +++ b/security/compliance/rvn-aws-compliance-definition.yml @@ -162,7 +162,7 @@ module: Basic scans check operating-system vulnerabilities on new image pushes. They do not provide continuous scanning or automatically scan all images pushed before this configuration was enabled. - ## Existing resources and ownership + ## CloudTrail CloudTrail is enabled by default and creates exactly one multi-Region trail in the runner Region. It records read and write management events, including global service events, with log file validation. No data events are recorded. @@ -174,6 +174,8 @@ module: Destroy fails while the bucket contains logs or object versions: force deletion is disabled. Disabling CloudTrail also attempts to remove its bucket, so preserve or explicitly remove retained logs before doing so. + ## Existing resources and ownership + GuardDuty detectors and ECR scanning configurations are shared by the whole account in each Region. Keep exactly one Terraform owner per account and Region; do not deploy overlapping instances or also manage them from a VPC or service module. If GuardDuty is already enabled, import the existing detectors into this module's Terraform state before applying. Do not disable GuardDuty just to adopt this module. Existing protection-plan settings are reconciled to the selected inputs. From ecc03088fe36fdae695062c647f0294a192e8aee Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:33:55 +0000 Subject: [PATCH 3/5] Document that the CloudTrail KMS key must be in the runner Region Co-Authored-By: brandon --- security/compliance/README.md | 2 +- security/compliance/rvn-aws-compliance-definition.yml | 6 +++--- security/compliance/variables.tf | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/security/compliance/README.md b/security/compliance/README.md index 0b61a98e..7caed25e 100644 --- a/security/compliance/README.md +++ b/security/compliance/README.md @@ -74,7 +74,7 @@ The first copy of management events is free; an existing trail makes this a paid Current and noncurrent versions expire after the configured retention period (365 days by default); 0 disables expiry. Current-object expiry creates a delete marker before noncurrent expiry applies. Destroy, including disabling CloudTrail, fails while logs or versions remain in the bucket because force deletion is disabled. -A supplied KMS key encrypts trail deliveries and bucket objects. Its key policy must allow cloudtrail.amazonaws.com; this module does not manage that policy. +A supplied KMS key encrypts trail deliveries and bucket objects. It must be in the runner Region and its key policy must allow cloudtrail.amazonaws.com; this module does not manage that policy. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | diff --git a/security/compliance/rvn-aws-compliance-definition.yml b/security/compliance/rvn-aws-compliance-definition.yml index c9839303..f0e945bd 100644 --- a/security/compliance/rvn-aws-compliance-definition.yml +++ b/security/compliance/rvn-aws-compliance-definition.yml @@ -41,7 +41,7 @@ module: label: KMS key ARN type: string required: false - description: Leave blank for SSE-S3. The key policy must allow cloudtrail.amazonaws.com. + description: Leave blank for SSE-S3. Use a key in the runner Region whose policy allows cloudtrail.amazonaws.com. show_when: cloudtrail_enabled: true - id: section_protection_plans @@ -168,7 +168,7 @@ module: Logs go to a dedicated private S3 bucket with public access blocked, bucket-owner-enforced ownership, versioning, and encryption. Log retention (days) sets lifecycle expiry for current and noncurrent object versions; 365 is the default and 0 keeps them forever. With versioning, current-object expiry makes a version noncurrent, then noncurrent expiry applies. - Leave KMS key ARN blank for SSE-S3, or supply a key whose policy allows cloudtrail.amazonaws.com to encrypt logs. This module does not manage the key policy. + Leave KMS key ARN blank for SSE-S3, or supply a key in the runner Region whose policy allows cloudtrail.amazonaws.com to encrypt logs. This module does not manage the key policy. The first copy of management events is free; S3 storage and optional KMS usage incur charges. If an existing trail already records these events, this trail is a paid second copy. Turn CloudTrail off if an organization trail already provides coverage. @@ -194,7 +194,7 @@ module: | ECR scanning | Basic scan-on-push for all repositories | Always enabled by this module. | | CloudTrail | Enabled | One multi-Region management-event trail in the runner Region. | | Log retention (days) | 365 | S3 lifecycle expiry; 0 keeps logs forever. | - | KMS key ARN | None (SSE-S3) | Optional key whose policy permits CloudTrail encryption. | + | KMS key ARN | None (SSE-S3) | Optional runner-Region key whose policy permits CloudTrail encryption. | | S3, EKS, malware, RDS, Lambda protection | Enabled | GuardDuty protection plans. | | Runtime monitoring | Disabled | Optional workload security agents. | | Automated agent management | EKS, ECS Fargate, EC2 | Used when Runtime Monitoring is enabled. | diff --git a/security/compliance/variables.tf b/security/compliance/variables.tf index 52eaad4b..f5200747 100644 --- a/security/compliance/variables.tf +++ b/security/compliance/variables.tf @@ -23,7 +23,7 @@ variable "cloudtrail_log_retention_days" { variable "cloudtrail_kms_key_arn" { type = string - description = "Optional KMS key ARN. Its policy must allow cloudtrail.amazonaws.com; null uses SSE-S3." + description = "Optional KMS key ARN in the runner Region. Its policy must allow cloudtrail.amazonaws.com; null uses SSE-S3." default = null validation { From 6f5a3c94fb90791cb242649661b4a544dcaf18ef Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:28:54 +0000 Subject: [PATCH 4/5] Create CloudTrail trails in each selected compliance Region Co-Authored-By: brandon --- security/compliance/README.md | 22 +++++++------ security/compliance/cloudtrail.tf | 12 ++++--- security/compliance/cloudtrail_bucket.tf | 4 +-- security/compliance/outputs.tf | 8 ++--- .../rvn-aws-compliance-definition.yml | 30 +++++++++-------- .../compliance/tests/cloudtrail.tftest.hcl | 33 ++++++++++++------- security/compliance/variables.tf | 8 ++--- 7 files changed, 68 insertions(+), 49 deletions(-) diff --git a/security/compliance/README.md b/security/compliance/README.md index 7caed25e..f56aeba9 100644 --- a/security/compliance/README.md +++ b/security/compliance/README.md @@ -1,11 +1,11 @@ # AWS compliance module -Manages GuardDuty threat detection, registry-wide Basic ECR image scanning on push, and a multi-Region CloudTrail audit trail for one AWS account. Deploy `rvn-aws-compliance` once per account with all required Regions. +Manages GuardDuty threat detection, registry-wide Basic ECR image scanning on push, and regional CloudTrail audit trails for one AWS account. Deploy `rvn-aws-compliance` once per account with all required Regions. The Regions list controls all three services; the runner Region hosts the shared log bucket. ## Features - Registry-wide ECR Basic `SCAN_ON_PUSH` with a `*` filter for all current and future private repositories, including Lambda image repositories. -- One validated multi-Region CloudTrail management-event trail with private, versioned log storage in the runner Region. +- One validated single-Region CloudTrail management-event trail per selected Region, sharing private, versioned log storage in the runner Region. - One `aws_guardduty_detector` per Region, driven by the AWS provider's per-resource `region` argument (no provider alias per Region). - Fails at plan time when a requested Region is not enabled (opted in) for the account. - Explicit per-Region protection plans: S3 Protection, EKS Protection, Malware Protection for EC2, RDS Protection, Lambda Protection, and Runtime Monitoring with automated agent management for EKS, ECS Fargate, and EC2. @@ -68,20 +68,22 @@ The AWS provider 6.x per-resource `region` argument is required. The credentials ## Inputs -CloudTrail is enabled by default: one multi-Region trail in the provider's runner Region records read/write management events and global service events, with log file validation and no data events. Its dedicated private S3 bucket has versioning, bucket-owner-enforced ownership and SSE-S3 encryption by default. +CloudTrail is enabled by default: a single-Region trail in each selected Region records read/write management events with log file validation and no data events. The dedicated shared S3 bucket stays in the runner Region, with versioning, bucket-owner-enforced ownership and SSE-S3 encryption by default. -The first copy of management events is free; an existing trail makes this a paid second copy. Disable CloudTrail when an organization trail already covers the account. S3 storage and optional KMS usage incur charges. +Include us-east-1 in Regions to capture IAM changes and other global service events (CloudFront and the STS global endpoint). Only that trail includes global service events; us-east-1 is never added automatically. STS regional-endpoint events are recorded in their respective Regions. + +The first copy of management events per Region is free; an existing trail recording those events makes this a paid additional copy in that Region. Disable CloudTrail when an organization trail already provides coverage. S3 storage and optional KMS usage incur charges. Current and noncurrent versions expire after the configured retention period (365 days by default); 0 disables expiry. Current-object expiry creates a delete marker before noncurrent expiry applies. Destroy, including disabling CloudTrail, fails while logs or versions remain in the bucket because force deletion is disabled. -A supplied KMS key encrypts trail deliveries and bucket objects. It must be in the runner Region and its key policy must allow cloudtrail.amazonaws.com; this module does not manage that policy. +A supplied KMS key encrypts trail deliveries and bucket objects. It must be in the bucket's runner Region, even for trails in other Regions. Its key policy must allow cloudtrail.amazonaws.com for every selected trail; this module does not manage that policy. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | -| `regions` | AWS Regions where GuardDuty and ECR scan-on-push are enabled. Each must be enabled for the account. | `list(string)` | n/a | yes | -| `cloudtrail_enabled` | Create one multi-Region management-event trail. | `bool` | `true` | no | +| `regions` | Regions for GuardDuty, ECR scanning and CloudTrail. Include us-east-1 for IAM changes. Each must be enabled for the account. | `list(string)` | n/a | yes | +| `cloudtrail_enabled` | Create a single-Region management-event trail in each selected Region. | `bool` | `true` | no | | `cloudtrail_log_retention_days` | Current and noncurrent log expiry; 0 keeps logs forever. | `number` | `365` | no | -| `cloudtrail_kms_key_arn` | Optional key whose policy permits CloudTrail encryption; null uses SSE-S3. | `string` | `null` | no | +| `cloudtrail_kms_key_arn` | Key in the bucket's runner Region whose policy permits encryption for every trail; null uses SSE-S3. | `string` | `null` | no | | `finding_publishing_frequency` | Export cadence for updated findings: `FIFTEEN_MINUTES`, `ONE_HOUR`, or `SIX_HOURS`. | `string` | `"FIFTEEN_MINUTES"` | no | | `s3_protection_enabled` | Enable S3 Protection (`S3_DATA_EVENTS`). | `bool` | `true` | no | | `eks_protection_enabled` | Enable EKS Protection (`EKS_AUDIT_LOGS`). | `bool` | `true` | no | @@ -96,8 +98,8 @@ A supplied KMS key encrypts trail deliveries and bucket objects. It must be in t | Name | Description | | ---- | ----------- | -| `regions` | Sorted list of Regions covered by GuardDuty and ECR scanning. | -| `cloudtrail_arn` | Trail ARN, or null when disabled. | +| `regions` | Sorted list of Regions covered by GuardDuty, ECR scanning and optional CloudTrail. | +| `cloudtrail_arns` | Map of Region to trail ARN, empty when disabled. | | `cloudtrail_bucket_name` | Audit log bucket name, or null when disabled. | | `cloudtrail_bucket_arn` | Audit log bucket ARN, or null when disabled. | | `detector_ids` | Map of Region to detector ID. | diff --git a/security/compliance/cloudtrail.tf b/security/compliance/cloudtrail.tf index cc689936..efb1bd8e 100644 --- a/security/compliance/cloudtrail.tf +++ b/security/compliance/cloudtrail.tf @@ -1,15 +1,19 @@ locals { cloudtrail_name = "ravion-compliance" - cloudtrail_arn = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}" + cloudtrail_arns = { + for region in local.regions : region => "arn:${data.aws_partition.current.partition}:cloudtrail:${region}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}" + if var.cloudtrail_enabled + } } resource "aws_cloudtrail" "this" { - count = var.cloudtrail_enabled ? 1 : 0 + for_each = var.cloudtrail_enabled ? local.regions : toset([]) + region = each.key name = local.cloudtrail_name s3_bucket_name = aws_s3_bucket.cloudtrail[0].id - is_multi_region_trail = true - include_global_service_events = true + is_multi_region_trail = false + include_global_service_events = each.key == "us-east-1" enable_log_file_validation = true enable_logging = true kms_key_id = var.cloudtrail_kms_key_arn diff --git a/security/compliance/cloudtrail_bucket.tf b/security/compliance/cloudtrail_bucket.tf index 810cc3ff..c2fdbb43 100644 --- a/security/compliance/cloudtrail_bucket.tf +++ b/security/compliance/cloudtrail_bucket.tf @@ -77,7 +77,7 @@ resource "aws_s3_bucket_policy" "cloudtrail" { Principal = { Service = "cloudtrail.amazonaws.com" } Action = "s3:GetBucketAcl" Resource = aws_s3_bucket.cloudtrail[0].arn - Condition = { StringEquals = { "aws:SourceArn" = local.cloudtrail_arn } } + Condition = { StringEquals = { "aws:SourceArn" = values(local.cloudtrail_arns) } } }, { Sid = "CloudTrailWrite" @@ -87,7 +87,7 @@ resource "aws_s3_bucket_policy" "cloudtrail" { Resource = "${aws_s3_bucket.cloudtrail[0].arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*" Condition = { StringEquals = { - "aws:SourceArn" = local.cloudtrail_arn + "aws:SourceArn" = values(local.cloudtrail_arns) "s3:x-amz-acl" = "bucket-owner-full-control" } } diff --git a/security/compliance/outputs.tf b/security/compliance/outputs.tf index 73ce20e0..f1ac22a3 100644 --- a/security/compliance/outputs.tf +++ b/security/compliance/outputs.tf @@ -3,13 +3,13 @@ ################################################################################ output "regions" { - description = "AWS Regions where GuardDuty and registry-wide Basic ECR scan-on-push are enabled." + description = "AWS Regions covered by GuardDuty, registry-wide Basic ECR scan-on-push and optional CloudTrail." value = sort(var.regions) } -output "cloudtrail_arn" { - description = "Multi-Region CloudTrail trail ARN, or null when disabled." - value = var.cloudtrail_enabled ? aws_cloudtrail.this[0].arn : null +output "cloudtrail_arns" { + description = "Map of Region to CloudTrail trail ARN, empty when disabled." + value = { for region, trail in aws_cloudtrail.this : region => trail.arn } } output "cloudtrail_bucket_name" { diff --git a/security/compliance/rvn-aws-compliance-definition.yml b/security/compliance/rvn-aws-compliance-definition.yml index f0e945bd..045d57c1 100644 --- a/security/compliance/rvn-aws-compliance-definition.yml +++ b/security/compliance/rvn-aws-compliance-definition.yml @@ -1,10 +1,10 @@ definition: type: rvn-aws-compliance name: AWS Compliance - description: Manages an AWS compliance baseline with GuardDuty, ECR image scanning, and a multi-Region CloudTrail audit trail. + description: Manages an AWS compliance baseline with GuardDuty, ECR image scanning, and regional CloudTrail audit trails. release: version: 0.3.0 - description: Record management events across Regions with a private, versioned CloudTrail log bucket and configurable retention. + description: Record management events in each selected Region with CloudTrail trails sharing a private, versioned log bucket and configurable retention. module: inputs: - $include: ../../partials/inputs/aws-account.yml @@ -15,7 +15,7 @@ module: label: Compliance regions type: section - add_button_label: Add Region - description: AWS Regions where GuardDuty and Basic ECR scan-on-push are enabled. ECR scanning covers every repository and replaces existing Enhanced scanning or custom filters. Use one module per account with all required Regions; each Region must already be enabled. + description: Regions for GuardDuty, ECR scanning and CloudTrail. Include us-east-1 for IAM changes; each Region must be enabled. id: regions label: Regions required: true @@ -28,7 +28,7 @@ module: label: CloudTrail type: boolean default: true - description: Turn off if an organization trail already records management events. + description: One trail per selected Region. Turn off if an organization trail already covers them. - id: cloudtrail_log_retention_days label: Log retention (days) type: number @@ -41,7 +41,7 @@ module: label: KMS key ARN type: string required: false - description: Leave blank for SSE-S3. Use a key in the runner Region whose policy allows cloudtrail.amazonaws.com. + description: Leave blank for SSE-S3. Use a bucket-Region key whose policy allows cloudtrail.amazonaws.com for every trail. show_when: cloudtrail_enabled: true - id: section_protection_plans @@ -142,13 +142,13 @@ module: }) : [] >> readme: | - Manages an AWS compliance baseline with GuardDuty, ECR image scanning, and a multi-Region CloudTrail audit trail. + Manages an AWS compliance baseline with GuardDuty, ECR image scanning, and regional CloudTrail audit trails. ## Account-level setup - Deploy one module for an AWS account and select every Region you want covered. The runner Region controls where Terraform executes and hosts the CloudTrail trail and bucket; the Regions list controls GuardDuty and ECR scanning. Every selected Region must already be enabled for the account. + Deploy one module for an AWS account and select every Region you want covered. The runner Region controls where Terraform executes and hosts the shared CloudTrail bucket; the Regions list controls GuardDuty, ECR scanning and CloudTrail trails. Every selected Region must already be enabled for the account. - Each selected Region receives a GuardDuty detector with explicitly managed protection plans and an ECR registry scanning rule covering all existing and future private repositories. CloudTrail records management events across Regions; application alarms and application log retention remain in their respective modules. + Each selected Region receives a GuardDuty detector with explicitly managed protection plans, an ECR registry scanning rule covering all existing and future private repositories, and a CloudTrail trail when enabled. Application alarms and application log retention remain in their respective modules. ## GuardDuty @@ -164,13 +164,15 @@ module: ## CloudTrail - CloudTrail is enabled by default and creates exactly one multi-Region trail in the runner Region. It records read and write management events, including global service events, with log file validation. No data events are recorded. + CloudTrail is enabled by default and creates one single-Region trail in each selected Region. Each records read and write management events with log file validation and no data events. The trails share one bucket in the runner Region. + + Include us-east-1 in Regions to capture IAM changes and other global service events (CloudFront and the STS global endpoint). Only that trail includes global service events; us-east-1 is not added automatically. STS regional-endpoint events are recorded in their respective Regions. Logs go to a dedicated private S3 bucket with public access blocked, bucket-owner-enforced ownership, versioning, and encryption. Log retention (days) sets lifecycle expiry for current and noncurrent object versions; 365 is the default and 0 keeps them forever. With versioning, current-object expiry makes a version noncurrent, then noncurrent expiry applies. - Leave KMS key ARN blank for SSE-S3, or supply a key in the runner Region whose policy allows cloudtrail.amazonaws.com to encrypt logs. This module does not manage the key policy. + Leave KMS key ARN blank for SSE-S3, or supply a key in the bucket's runner Region, even for trails in other Regions. Its policy must allow cloudtrail.amazonaws.com to encrypt logs for every selected trail. This module does not manage the key policy. - The first copy of management events is free; S3 storage and optional KMS usage incur charges. If an existing trail already records these events, this trail is a paid second copy. Turn CloudTrail off if an organization trail already provides coverage. + The first copy of management events per Region is free; S3 storage and optional KMS usage incur charges. If an existing trail already records those events in a Region, the additional copy is paid. Turn CloudTrail off if an organization trail already provides coverage. Destroy fails while the bucket contains logs or object versions: force deletion is disabled. Disabling CloudTrail also attempts to remove its bucket, so preserve or explicitly remove retained logs before doing so. @@ -190,11 +192,11 @@ module: | --- | --- | --- | | AWS account | Required | Account whose regional security settings are managed. | | Region | Required | Terraform runner Region. | - | Regions | Required | Regions covered by both GuardDuty and ECR scanning. | + | Regions | Required | Regions covered by GuardDuty, ECR scanning and CloudTrail. Include us-east-1 for IAM changes. | | ECR scanning | Basic scan-on-push for all repositories | Always enabled by this module. | - | CloudTrail | Enabled | One multi-Region management-event trail in the runner Region. | + | CloudTrail | Enabled | One single-Region management-event trail in each selected Region, sharing a runner-Region bucket. | | Log retention (days) | 365 | S3 lifecycle expiry; 0 keeps logs forever. | - | KMS key ARN | None (SSE-S3) | Optional runner-Region key whose policy permits CloudTrail encryption. | + | KMS key ARN | None (SSE-S3) | Key in the bucket's runner Region whose policy permits encryption for every trail. | | S3, EKS, malware, RDS, Lambda protection | Enabled | GuardDuty protection plans. | | Runtime monitoring | Disabled | Optional workload security agents. | | Automated agent management | EKS, ECS Fargate, EC2 | Used when Runtime Monitoring is enabled. | diff --git a/security/compliance/tests/cloudtrail.tftest.hcl b/security/compliance/tests/cloudtrail.tftest.hcl index 99c42e26..e03109c1 100644 --- a/security/compliance/tests/cloudtrail.tftest.hcl +++ b/security/compliance/tests/cloudtrail.tftest.hcl @@ -23,20 +23,20 @@ variables { regions = ["us-east-1", "eu-west-1"] } -run "default_multi_region_trail" { +run "default_regional_trails" { command = plan assert { - condition = length(aws_cloudtrail.this) == 1 && aws_cloudtrail.this[0].is_multi_region_trail && aws_cloudtrail.this[0].include_global_service_events && aws_cloudtrail.this[0].enable_log_file_validation && aws_cloudtrail.this[0].enable_logging - error_message = "Exactly one validated multi-Region trail must record events by default." + condition = toset(keys(aws_cloudtrail.this)) == toset(var.regions) && alltrue([for region, trail in aws_cloudtrail.this : trail.region == region && !trail.is_multi_region_trail && trail.include_global_service_events == (region == "us-east-1") && trail.enable_log_file_validation && trail.enable_logging && trail.tags["Module"] == "security/compliance"]) + error_message = "Each selected Region must have a validated single-Region trail; only us-east-1 includes global events." } assert { - condition = one(aws_cloudtrail.this[0].event_selector).read_write_type == "All" && one(aws_cloudtrail.this[0].event_selector).include_management_events && length(one(aws_cloudtrail.this[0].event_selector).data_resource) == 0 + condition = alltrue([for trail in aws_cloudtrail.this : one(trail.event_selector).read_write_type == "All" && one(trail.event_selector).include_management_events && length(one(trail.event_selector).data_resource) == 0]) error_message = "Only read and write management events must be selected." } assert { - condition = local.cloudtrail_arn == "arn:aws:cloudtrail:us-east-2:123456789012:trail/ravion-compliance" - error_message = "The trail must use the runner Region, not the selected compliance Regions." + condition = local.cloudtrail_arns == { "us-east-1" = "arn:aws:cloudtrail:us-east-1:123456789012:trail/ravion-compliance", "eu-west-1" = "arn:aws:cloudtrail:eu-west-1:123456789012:trail/ravion-compliance" } && toset(keys(output.cloudtrail_arns)) == toset(var.regions) + error_message = "Trail ARNs and output keys must cover exactly the selected Regions." } assert { condition = !aws_s3_bucket.cloudtrail[0].force_destroy && aws_s3_bucket.cloudtrail[0].bucket_prefix == "ravion-cloudtrail-123456789012-" && aws_s3_bucket.cloudtrail[0].tags["Module"] == "security/compliance" @@ -47,7 +47,7 @@ run "default_multi_region_trail" { error_message = "Audit logs must remain private and bucket-owner enforced." } assert { - condition = one(aws_s3_bucket_versioning.cloudtrail[0].versioning_configuration).status == "Enabled" && one(one(aws_s3_bucket_server_side_encryption_configuration.cloudtrail[0].rule).apply_server_side_encryption_by_default).sse_algorithm == "AES256" && aws_cloudtrail.this[0].kms_key_id == null + condition = one(aws_s3_bucket_versioning.cloudtrail[0].versioning_configuration).status == "Enabled" && one(one(aws_s3_bucket_server_side_encryption_configuration.cloudtrail[0].rule).apply_server_side_encryption_by_default).sse_algorithm == "AES256" && alltrue([for trail in aws_cloudtrail.this : trail.kms_key_id == null]) error_message = "Versioning and SSE-S3 must be enabled by default." } assert { @@ -55,7 +55,7 @@ run "default_multi_region_trail" { error_message = "Current and noncurrent logs must expire after the configured period." } assert { - condition = jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[0].Condition.StringEquals["aws:SourceArn"] == local.cloudtrail_arn && jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[1].Condition.StringEquals["aws:SourceArn"] == local.cloudtrail_arn && jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[1].Condition.StringEquals["s3:x-amz-acl"] == "bucket-owner-full-control" && jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[2].Condition.Bool["aws:SecureTransport"] == "false" + condition = toset(jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[0].Condition.StringEquals["aws:SourceArn"]) == toset(values(local.cloudtrail_arns)) && toset(jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[1].Condition.StringEquals["aws:SourceArn"]) == toset(values(local.cloudtrail_arns)) && jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[1].Condition.StringEquals["s3:x-amz-acl"] == "bucket-owner-full-control" && jsondecode(aws_s3_bucket_policy.cloudtrail[0].policy).Statement[2].Condition.Bool["aws:SecureTransport"] == "false" error_message = "Delivery must be scoped to this trail, require ownership ACLs and deny insecure transport." } } @@ -70,8 +70,19 @@ run "disabled_creates_no_cloudtrail_resources" { error_message = "Disabling CloudTrail must create no trail or storage resources." } assert { - condition = output.cloudtrail_arn == null && output.cloudtrail_bucket_name == null && output.cloudtrail_bucket_arn == null - error_message = "CloudTrail outputs must be null when disabled." + condition = length(output.cloudtrail_arns) == 0 && output.cloudtrail_bucket_name == null && output.cloudtrail_bucket_arn == null + error_message = "Trail ARNs must be empty and bucket outputs null when disabled." + } +} + +run "without_us_east_1" { + command = plan + variables { + regions = ["eu-west-1"] + } + assert { + condition = length(aws_cloudtrail.this) == 1 && !aws_cloudtrail.this["eu-west-1"].include_global_service_events && length(aws_s3_bucket.cloudtrail) == 1 && !contains(keys(aws_cloudtrail.this), "us-east-1") + error_message = "Only selected Regions must receive trails; us-east-1 must not be added automatically." } } @@ -93,7 +104,7 @@ run "custom_retention_and_kms" { cloudtrail_kms_key_arn = "arn:aws:kms:us-east-2:123456789012:key/11111111-2222-3333-4444-555555555555" } assert { - condition = aws_cloudtrail.this[0].kms_key_id == var.cloudtrail_kms_key_arn && one(one(aws_s3_bucket_server_side_encryption_configuration.cloudtrail[0].rule).apply_server_side_encryption_by_default).kms_master_key_id == var.cloudtrail_kms_key_arn && one(one(aws_s3_bucket_server_side_encryption_configuration.cloudtrail[0].rule).apply_server_side_encryption_by_default).sse_algorithm == "aws:kms" + condition = alltrue([for trail in aws_cloudtrail.this : trail.kms_key_id == var.cloudtrail_kms_key_arn]) && one(one(aws_s3_bucket_server_side_encryption_configuration.cloudtrail[0].rule).apply_server_side_encryption_by_default).kms_master_key_id == var.cloudtrail_kms_key_arn && one(one(aws_s3_bucket_server_side_encryption_configuration.cloudtrail[0].rule).apply_server_side_encryption_by_default).sse_algorithm == "aws:kms" error_message = "The provided key must encrypt both trail deliveries and bucket objects." } assert { diff --git a/security/compliance/variables.tf b/security/compliance/variables.tf index f5200747..8ccb20db 100644 --- a/security/compliance/variables.tf +++ b/security/compliance/variables.tf @@ -4,7 +4,7 @@ variable "cloudtrail_enabled" { type = bool - description = "Create a multi-Region management-event trail in the runner Region." + description = "Create a single-Region management-event trail in each selected Region." default = true nullable = false } @@ -23,7 +23,7 @@ variable "cloudtrail_log_retention_days" { variable "cloudtrail_kms_key_arn" { type = string - description = "Optional KMS key ARN in the runner Region. Its policy must allow cloudtrail.amazonaws.com; null uses SSE-S3." + description = "Optional KMS key ARN in the bucket's runner Region. Its policy must allow cloudtrail.amazonaws.com for every trail; null uses SSE-S3." default = null validation { @@ -35,7 +35,7 @@ variable "cloudtrail_kms_key_arn" { variable "regions" { type = list(string) nullable = false - description = "AWS Regions where GuardDuty and registry-wide Basic ECR scan-on-push are enabled. Every Region must be enabled (opted in) for the account." + description = "Regions for GuardDuty, ECR scanning and optional CloudTrail. Include us-east-1 to capture IAM changes. Each Region must be enabled for the account." validation { condition = length(var.regions) > 0 @@ -137,6 +137,6 @@ variable "runtime_monitoring_automated_agents" { variable "tags" { type = map(string) - description = "A map of additional tags applied to GuardDuty detectors, the CloudTrail trail and its bucket." + description = "A map of additional tags applied to GuardDuty detectors, CloudTrail trails and their bucket." default = {} } From 750f84e409c0302547a63e9a2db56b4b9d67fc39 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:31:51 +0000 Subject: [PATCH 5/5] Remove unused runner Region lookup after regionalizing trails Co-Authored-By: brandon --- security/compliance/data.tf | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/security/compliance/data.tf b/security/compliance/data.tf index 6c007055..11e01b9a 100644 --- a/security/compliance/data.tf +++ b/security/compliance/data.tf @@ -1,10 +1,8 @@ data "aws_caller_identity" "current" {} -data "aws_region" "current" {} - data "aws_partition" "current" {} -# Both services require the selected Regions to be enabled for the account. +# All services require the selected Regions to be enabled for the account. data "aws_regions" "enabled" { all_regions = false }