diff --git a/README.md b/README.md index 81d54a26..426960e2 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.1 | `networking/alb/` | | `rvn-aws-alternat` | alterNAT | v0.1.3 | `networking/alternat/` | | `rvn-aws-ami` | AMI | v0.3.2 | `compute/ami/` | -| `rvn-aws-compliance` | AWS Compliance | v0.2.1 | `security/compliance/` | +| `rvn-aws-compliance` | AWS Compliance | v0.3.0 | `security/compliance/` | | `rvn-aws-iam-policy` | AWS IAM Policy | v1.1.2 | `security/iam_policy/` | | `rvn-aws-iam-role` | AWS IAM Role | v1.1.2 | `security/iam/` | | `rvn-aws-kms` | AWS KMS Key | v0.2.1 | `security/kms/` | diff --git a/security/compliance/README.md b/security/compliance/README.md index e68699ff..f56aeba9 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 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 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. @@ -67,9 +68,22 @@ The AWS provider 6.x per-resource `region` argument is required. The credentials ## Inputs +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. + +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 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 | +| `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` | 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 | @@ -84,7 +98,10 @@ 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. | +| `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. | | `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..efb1bd8e --- /dev/null +++ b/security/compliance/cloudtrail.tf @@ -0,0 +1,33 @@ +locals { + cloudtrail_name = "ravion-compliance" + 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" { + 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 = 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 + + 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..c2fdbb43 --- /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" = values(local.cloudtrail_arns) } } + }, + { + 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" = values(local.cloudtrail_arns) + "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..11e01b9a 100644 --- a/security/compliance/data.tf +++ b/security/compliance/data.tf @@ -1,6 +1,8 @@ data "aws_caller_identity" "current" {} -# Both services require the selected Regions to be enabled for the account. +data "aws_partition" "current" {} + +# All 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..f1ac22a3 100644 --- a/security/compliance/outputs.tf +++ b/security/compliance/outputs.tf @@ -3,10 +3,25 @@ ################################################################################ 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_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" { + 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 d8fe7727..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 threat detection and registry-wide ECR image scanning across selected Regions. + description: Manages an AWS compliance baseline with GuardDuty, ECR image scanning, and regional CloudTrail audit trails. release: - version: 0.2.1 - description: 'Lock the AWS provider to 6.69.1 (from 6.69.0), fixing plans that failed with `bufio.Scanner: token too long` on large AWS responses (e.g. Lambda functions with many published versions).' + version: 0.3.0 + 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,12 +15,35 @@ 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 type: string_array values: $values:aws/regions + - id: section_cloudtrail + label: CloudTrail + type: section + - id: cloudtrail_enabled + label: CloudTrail + type: boolean + default: true + 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 + 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. Use a bucket-Region key whose policy allows cloudtrail.amazonaws.com for every trail. + 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 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; 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 shared CloudTrail bucket; the Regions list controls GuardDuty, ECR scanning and CloudTrail trails. 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, 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 @@ -136,6 +162,20 @@ 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. + ## CloudTrail + + 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 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 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. + ## 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. @@ -152,13 +192,16 @@ 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 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) | 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. | | 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..e03109c1 --- /dev/null +++ b/security/compliance/tests/cloudtrail.tftest.hcl @@ -0,0 +1,138 @@ +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_regional_trails" { + command = plan + + assert { + 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 = 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_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" + 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" && alltrue([for trail in aws_cloudtrail.this : trail.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 = 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." + } +} + +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 = 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." + } +} + +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 = 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 { + 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..8ccb20db 100644 --- a/security/compliance/variables.tf +++ b/security/compliance/variables.tf @@ -2,10 +2,40 @@ # Regions ################################################################################ +variable "cloudtrail_enabled" { + type = bool + description = "Create a single-Region management-event trail in each selected 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 in the bucket's runner Region. Its policy must allow cloudtrail.amazonaws.com for every trail; 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 - 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 @@ -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, CloudTrail trails and their bucket." default = {} }