diff --git a/CLAUDE.md b/CLAUDE.md index aee0e97..2615a7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ Migration happens later per-app at developer's pace — apps move from old ALB/E | `docs/org-runbook.md` | AWS Organizations setup procedure | | `docs/cognito-google-setup.md` | Cognito + Google Workspace IdP setup | | `docs/apply-gate.md` | Apply gate: credential broker, HMAC overrides, security model | +| `docs/cost-management.md` | Budgets, anomaly detection, cost-allocation tags, CUR, Config recording, log retention | ### Terraform — Platform (CI-applied) ``` @@ -225,6 +226,8 @@ EventBridge ──► javabin-security SNS ──► slack-alert Lambda: IAM / resource / login events ──► #javabin-infra-alerts Cost Anomaly ──► javabin-alerts SNS ──► slack-alert Lambda ──► #javabin-cost-alerts +Account budget (FORECASTED >100%, ACTUAL >100%) ──► javabin-alerts SNS ──► #javabin-cost-alerts + Scheduled: Monday 08:00 UTC ──► cost-report ──► #javabin-cost-alerts Monday 08:00 UTC ──► securityhub-summary ──► #platform-security-alerts @@ -316,7 +319,7 @@ Team group OWNERs are admin-managed and never removed by the sync; non-@java.no - **App databases share one network path**: service-rds allows ingress from the shared ECS tasks SG, so every team's tasks can reach every team's DB port (password still required). Fix: per-app security groups - **Team provisioner Lambda**: All sync functions working (Google/GitHub/Budget/Cognito/Identity Center). Password-set flow deployed. - **`registered_app_repos` manually managed**: Being replaced with team-scoped IAM roles (repo→team resolved via GitHub API at runtime) -- **Cost allocation tags pending activation**: `repo`, `created-by`, `commit` tags need activation in Billing console (requires billing data to appear first) +- **Cost allocation tags**: `team`, `service`, `environment`, `managed-by`, `repo` are active (Terraform). `created-by` and `commit` stay inactive on purpose (no cost signal) - **Platform-test-app naming migration**: Existing resources have old `javabin-` prefix names, needs state migration to `{team}-{service}` naming ## Agent Guidelines diff --git a/docs/cost-management.md b/docs/cost-management.md new file mode 100644 index 0000000..e6b4921 --- /dev/null +++ b/docs/cost-management.md @@ -0,0 +1,119 @@ +# Cost Management + +How the platform watches AWS spend, and where each piece lives. All of it is +platform Terraform or a platform Lambda; nothing here is configured by hand. + +Steady state (2026-10): about **$550/month including 25% VAT** (~$440 before +tax), flat since April 2026. Most of it is fixed cost: load balancers, public +IPv4 addresses, the NAT gateway, AWS Config, Security Hub and the legacy +pre-platform stacks. Budgets and Cost Explorer report cost **with tax**. + +## Alerts + +All cost alerts go to the `javabin-alerts` SNS topic, which the `slack-alert` +Lambda posts to **#javabin-cost-alerts**. + +| Signal | Where | Fires when | +|---|---|---| +| Account budget `javabin-account-monthly` | `terraform/platform/cost-analytics/main.tf` | Forecast for the month > 100%; actual > 100%; actual > 150% | +| Cost Anomaly Detection `javabin-anomaly-monitor` | `terraform/platform/monitoring/main.tf` | Per-service anomaly with total impact >= $10 (IMMEDIATE) | +| Team budgets `javabin-team-` | `team-provisioner` Lambda (from registry `budget_nok`, default 500 NOK) | Actual > 80% (Slack); actual > 200% triggers `budget-enforcer` | +| `budget-enforcer` | `terraform/lambda-src/budget_enforcer` | Scales the team's ECS services to `desired_count=0` (nothing is deleted) | +| `daily-cost-check` / `cost-report` | `terraform/lambda-src/` | Daily spike check; weekly breakdown every Monday | + +The account budget limit is `account_budget_usd` in `terraform/platform/main.tf` +(currently $600, just above steady state). **Lower it as the legacy cleanup +lands**, otherwise it stops catching drift. A budget that alerts every month is +noise; one set far above spend is useless. + +There is deliberately no CloudWatch `EstimatedCharges` billing alarm. That +metric needs "Receive Billing Alerts" enabled on the account and only exists in +us-east-1; the old alarm sat in `INSUFFICIENT_DATA` with an SNS topic nobody +subscribed to. The budget's FORECASTED notification is the early warning. + +A per-team anomaly monitor was considered and skipped: a tag-based +`aws_ce_anomaly_monitor` needs every team value listed explicitly and is +replaced whenever the list changes, and per-team budgets already cover it. + +## Attribution: cost-allocation tags + +`team`, `service`, `environment`, `managed-by` and `repo` are activated as +cost-allocation tags (`aws_ce_cost_allocation_tag.tags` in +`monitoring/main.tf`). Apps deployed through `app.yaml` get them from the +generated module's `default_tags`, and ECS tasks inherit them through +`propagate_tags = "SERVICE"`. + +Spend without a `team` tag is expected for: tax, AWS Config, Security Hub, +public IPv4 charges, the shared NAT gateway and ALB, and every legacy +pre-platform stack. In Cost Explorer group by `Tag: team` and read the +"No tag key" bucket as "platform overhead + legacy". + +## Detailed analysis: CUR + Athena + +CUR 2.0 (daily, with resource IDs) is exported to `javabin-cur-`, +crawled by Glue into the `javabin_cur` database, and queryable from the +`javabin-cost-analytics` Athena workgroup (100 MB scan limit per query). +Use it for per-resource questions Cost Explorer cannot answer. + +## Built-in cost controls + +| Control | Where | Setting | +|---|---|---| +| Platform Lambda log retention | `terraform/platform/lambdas/log-groups.tf`, `identity/log-groups.tf` | 365 days. Every new Lambda must be added to `local.platform_lambda_names` (`scripts/tests/test_lambda_log_groups.py` enforces it) | +| App log retention | `terraform/modules/ecs-service` | 30 days | +| ECR image lifecycle | `terraform/modules/ecr-repo`, CI repos in `compute` | Expire old/untagged images | +| AWS Config recording | `terraform/platform/monitoring/main.tf` | All supported types; `AWS::EC2::NetworkInterface`, VPC and Subnet recorded daily. See below | +| Config history bucket | `monitoring/main.tf` | Objects expire after 730 days | +| CUR bucket | `cost-analytics/main.tf` | IA after 90 days, expire after 365 | +| Athena results | `cost-analytics/main.tf` | Expire after 7 days | + +### Why Config records ENIs daily + +AWS Config charges per configuration item ($0.003 each). Every Fargate task +start/stop creates and deletes an ENI in the platform private subnets, and +each change re-records the ENI plus its VPC and subnet. In Sept 2026 the +account recorded ~13k items ($39); ENI, VPC and Subnet were ~7.3k of them +(~$22), almost all caused by one crash-looping service (`testteam-platform-test-app`, +~80 task starts a day). + +ENIs are **not** excluded from recording, because that would weaken Security +Hub: + +- **EC2.180** (ENI source/destination check) is enabled and its Config rule is + scoped to `AWS::EC2::NetworkInterface`. Without ENI recording it goes stale. +- **Config.1** fails when Config does not record every resource type an + enabled control needs. (It already fails today for IAM global types and the + custom recorder role; excluding ENIs would add a third reason.) + +Instead ENI, VPC and Subnet use a DAILY recording override: at most one item +per changed resource per day, and EC2.180 still evaluates every ENI that +exists at the daily snapshot. Short-lived ENIs created and deleted within the +same day may not be recorded at all. Security groups stay continuous because +SG changes are security-relevant. + +The saving depends on churn. With the crash loop it is roughly $15–20/month; +once the crash loop is fixed, steady-state churn is a few dollars a month at +most. Fixing the crashing service is the bigger lever. + +After apply, check for 7 days: + +- `AWS/Config` `ConfigurationItemsRecorded` per `ResourceType`. If + `AWS::EC2::SecurityGroup` dominates, consider adding it to the override. +- Security Hub **EC2.180** still has fresh evaluations (not NO_DATA). +- Security Hub **Config.1** has no new reason mentioning + `AWS::EC2::NetworkInterface`. + +### Account budget + +`account_budget_usd` is set about 10% above steady-state spend, with +FORECASTED >100% and ACTUAL >100% notifications. Lower it each time a cleanup +from the cost audit lands, or the budget stops catching real overruns. + +## Known savings outside platform Terraform + +The 2026-10 cost audit (kept outside the repo, in the maintainers' +`explore/cost-audit-report.md`) lists legacy resources that cost money and are +not owned by platform Terraform: idle Elastic Beanstalk environments, the +legacy `Default-ALB` and its public IPs, the Coolify host, the NAT gateway +design, and Savings Plan timing. Those are cleanup decisions for their owners, +not things the platform changes automatically. diff --git a/docs/lambda-functions.md b/docs/lambda-functions.md index ad0304c..ec647e7 100644 --- a/docs/lambda-functions.md +++ b/docs/lambda-functions.md @@ -2,6 +2,7 @@ Source code in `terraform/lambda-src/{function}/handler.py`. Shared module in `terraform/lambda-src/shared/`. Terraform in `terraform/platform/lambdas/`. +Log groups `/aws/lambda/javabin-{function}` are declared in `lambdas/log-groups.tf` (365-day retention); add every new function to `local.platform_lambda_names` (enforced by `scripts/tests/test_lambda_log_groups.py`). ## slack-alert diff --git a/docs/platform-modules.md b/docs/platform-modules.md index b52b603..b0e4999 100644 --- a/docs/platform-modules.md +++ b/docs/platform-modules.md @@ -78,10 +78,11 @@ SNS topics, EventBridge rules, Config, GuardDuty, Security Hub, account guardrai | `javabin-alerts` SNS | Cost anomalies, budgets | | `javabin-security` SNS | IAM changes, GuardDuty, Config, Security Hub | | EventBridge rules | IAM key creation, root login, console sign-in, resource creation, security tampering (CloudTrail/Config/GuardDuty/Security Hub/KMS/guardrail disables, plus GuardDuty filters, Security Hub automation rules and control changes, Config recorder changes). `UpdateDetector`, `UpdateServiceSetting` and the filter/rule events also fire on benign changes, including platform applies | -| AWS Config | Configuration recorder (service-linked role, includes global IAM types) + S3 delivery (TLS-only, 2-year expiry) | +| AWS Config | Configuration recorder (service-linked role, includes global IAM types; ENI/VPC/Subnet recorded daily) + S3 delivery (TLS-only, 2-year expiry) | | Account guardrails | EBS snapshot block-public-access (block all), AMI block-public-access (block new), EBS encryption by default, SSM document public sharing off | | IAM Access Analyzer | `javabin-external-access`, external-access findings only (free) | | Inspector | ECR + Lambda package scanning (EC2 and Lambda code scanning off). Enabling it switches ECR registry scanning from BASIC to ENHANCED, so `scan_on_push` on repositories (`compute/`, `modules/ecr-repo`) no longer has an effect. Expect a first burst of findings into Security Hub, mostly from legacy Lambdas on deprecated runtimes | +| Cost Anomaly Detection | SERVICE monitor, >= $10 impact → `javabin-alerts` | | GuardDuty | Threat detection | | Security Hub | Findings aggregation | | `javabin-alert-dedup` DynamoDB | Deduplication table used by slack-alert Lambda | diff --git a/scripts/tests/test_lambda_log_groups.py b/scripts/tests/test_lambda_log_groups.py new file mode 100644 index 0000000..b49e70a --- /dev/null +++ b/scripts/tests/test_lambda_log_groups.py @@ -0,0 +1,93 @@ +"""Every platform Lambda must have a Terraform-managed log group with retention. + +Lambda auto-creates /aws/lambda/ with "Never expire" if the group is not +declared, so a new function added anywhere under terraform/platform without a +matching log group (local.platform_lambda_names in lambdas/log-groups.tf, or an +explicit aws_cloudwatch_log_group in its own module) would log forever. + +Run: python3 -m unittest discover -s scripts/tests +""" + +import glob +import os +import re +import unittest + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "terraform", "platform") + + +def _read(*parts): + with open(os.path.join(ROOT, *parts)) as f: + return f.read() + + +def _toset_names(block): + return set(re.findall(r'^\s*"([a-z0-9-]+)",\s*$', block, flags=re.M)) + + +def _platform_tf_sources(): + for path in sorted(glob.glob(os.path.join(ROOT, "**", "*.tf"), recursive=True)): + with open(path) as f: + yield path, f.read() + + +FUNCTION_NAME = re.compile(r'function_name\s*=\s*"\$\{var\.project\}-([a-z0-9-]+)"') +EXPLICIT_LOG_GROUP = re.compile(r'name\s*=\s*"/aws/lambda/\$\{var\.project\}-([a-z0-9-]+)"') + + +def lambda_function_names(): + """Short names of every Lambda declared under terraform/platform.""" + names = set() + for _, src in _platform_tf_sources(): + names |= set(FUNCTION_NAME.findall(src)) + return names + + +def shared_log_group_names(): + """Names in local.platform_lambda_names (the for_each in lambdas/log-groups.tf).""" + src = _read("lambdas", "log-groups.tf") + block = re.search(r"platform_lambda_names\s*=\s*toset\(\[(.*?)\]\)", src, flags=re.S).group(1) + return _toset_names(block) + + +def log_group_names(): + """Shared for_each names plus explicit /aws/lambda/${var.project}- groups in any module.""" + names = shared_log_group_names() + for _, src in _platform_tf_sources(): + names |= set(EXPLICIT_LOG_GROUP.findall(src)) + return names + + +def imported_names(): + src = _read("imports.tf") + block = re.search( + r"import\s*\{\s*for_each\s*=\s*toset\(\[(.*?)\]\)\s*to\s*=\s*module\.lambdas\.aws_cloudwatch_log_group\.lambda", + src, + flags=re.S, + ).group(1) + return _toset_names(block) + + +class LambdaLogGroupTests(unittest.TestCase): + def test_every_function_has_a_log_group(self): + functions = lambda_function_names() + self.assertTrue(functions, "no aws_lambda_function found; did the naming pattern change?") + self.assertIn("auth-claims", functions, "identity module Lambda not discovered; scan is too narrow") + missing = functions - log_group_names() + self.assertFalse( + missing, + "declare a log group with retention (local.platform_lambda_names in lambdas/log-groups.tf, " + f"or an aws_cloudwatch_log_group in the function's module): {sorted(missing)}", + ) + + def test_no_log_group_without_function(self): + stale = log_group_names() - lambda_function_names() + self.assertFalse(stale, f"log groups for functions that no longer exist: {sorted(stale)}") + + def test_imports_only_reference_declared_groups(self): + unknown = imported_names() - shared_log_group_names() + self.assertFalse(unknown, f"import blocks for undeclared log groups: {sorted(unknown)}") + + +if __name__ == "__main__": + unittest.main() diff --git a/terraform/platform/cost-analytics/main.tf b/terraform/platform/cost-analytics/main.tf index adfe29b..84291d2 100644 --- a/terraform/platform/cost-analytics/main.tf +++ b/terraform/platform/cost-analytics/main.tf @@ -7,8 +7,11 @@ # Glue database Catalog for CUR data # Glue crawler Auto-discovers CUR Parquet schema daily # Athena workgroup Scoped workgroup for cost queries -# Billing alarm CloudWatch alarm on EstimatedCharges (~4-6h delay) -# Account budget Monthly budget with notifications at 80% and 100% +# Account budget Monthly budget: forecast >100%, actual >100%, actual >150% +# +# There is no CloudWatch EstimatedCharges alarm: the AWS/Billing metric is not +# enabled for this account (alarm sat in INSUFFICIENT_DATA from 2026-03 until it +# was removed). The budget's FORECASTED notification is the early warning. ################################################################################ terraform { @@ -322,40 +325,6 @@ resource "aws_athena_workgroup" "cur" { } } -################################################################################ -# Billing protection — CloudWatch billing alarm (us-east-1) -################################################################################ - -# SNS topic in us-east-1 — CloudWatch billing alarms can only target same-region SNS. -# A Lambda forwarder (us-east-1 → eu-central-1) can be added later for Slack integration. -# For now, the budget notifications below go directly to the main alerts topic. -resource "aws_sns_topic" "billing_alarm" { - provider = aws.us_east_1 - name = "${var.project}-billing-alarm" -} - -resource "aws_cloudwatch_metric_alarm" "billing" { - provider = aws.us_east_1 - - alarm_name = "${var.project}-billing-alarm" - alarm_description = "Account estimated charges exceeded $${var.billing_alarm_threshold_usd}" - comparison_operator = "GreaterThanThreshold" - evaluation_periods = 1 - metric_name = "EstimatedCharges" - namespace = "AWS/Billing" - period = 21600 # 6 hours - statistic = "Maximum" - threshold = var.billing_alarm_threshold_usd - treat_missing_data = "missing" - - dimensions = { - Currency = "USD" - } - - alarm_actions = [aws_sns_topic.billing_alarm.arn] - ok_actions = [aws_sns_topic.billing_alarm.arn] -} - ################################################################################ # Billing protection — account-level AWS Budget (notifications only) ################################################################################ @@ -367,16 +336,16 @@ resource "aws_budgets_budget" "account" { limit_unit = "USD" time_unit = "MONTHLY" - # 80% warning + # Early warning: AWS forecasts the month will end above the budget. notification { comparison_operator = "GREATER_THAN" - notification_type = "ACTUAL" - threshold = 80 + notification_type = "FORECASTED" + threshold = 100 threshold_type = "PERCENTAGE" subscriber_sns_topic_arns = [var.alerts_topic_arn] } - # 100% critical + # Budget exceeded. notification { comparison_operator = "GREATER_THAN" notification_type = "ACTUAL" @@ -384,4 +353,13 @@ resource "aws_budgets_budget" "account" { threshold_type = "PERCENTAGE" subscriber_sns_topic_arns = [var.alerts_topic_arn] } + + # Critical: spend is 50% over budget. Something is running away. + notification { + comparison_operator = "GREATER_THAN" + notification_type = "ACTUAL" + threshold = 150 + threshold_type = "PERCENTAGE" + subscriber_sns_topic_arns = [var.alerts_topic_arn] + } } diff --git a/terraform/platform/cost-analytics/variables.tf b/terraform/platform/cost-analytics/variables.tf index 033e156..9303852 100644 --- a/terraform/platform/cost-analytics/variables.tf +++ b/terraform/platform/cost-analytics/variables.tf @@ -18,16 +18,10 @@ variable "alerts_topic_arn" { type = string } -variable "billing_alarm_threshold_usd" { - description = "CloudWatch billing alarm threshold in USD" - type = number - default = 200 -} - variable "account_budget_usd" { - description = "Account-level monthly budget in USD — notifications at 80% and 100%" + description = "Account-level monthly budget in USD, tax included (Cost Explorer UnblendedCost incl. 25% VAT). Notifies on forecast >100%, actual >100% and actual >150%. Steady state is ~$550/mo (2026-10); lower this as the legacy cleanup in docs/cost-management.md lands." type = number - default = 500 + default = 600 } variable "org_boundary_arn" { diff --git a/terraform/platform/identity/log-groups.tf b/terraform/platform/identity/log-groups.tf new file mode 100644 index 0000000..2e314e3 --- /dev/null +++ b/terraform/platform/identity/log-groups.tf @@ -0,0 +1,13 @@ +# Bounded retention for the auth-claims Lambda's log group (auto-created by +# Lambda with "Never expire"; adopted by terraform/platform/imports.tf). +resource "aws_cloudwatch_log_group" "auth_claims" { + name = "/aws/lambda/${var.project}-auth-claims" + retention_in_days = 365 +} + +# The external pool's trigger (javabin-auth-claims-external) had not logged +# yet, so its group is created fresh rather than imported. +resource "aws_cloudwatch_log_group" "auth_claims_external" { + name = "/aws/lambda/${var.project}-auth-claims-external" + retention_in_days = 365 +} diff --git a/terraform/platform/imports.tf b/terraform/platform/imports.tf new file mode 100644 index 0000000..d6cdd14 --- /dev/null +++ b/terraform/platform/imports.tf @@ -0,0 +1,34 @@ +################################################################################ +# One-time adoption of resources that existed before Terraform managed them. +# +# Import blocks are no-ops once the resource is in state, so these can be +# removed in a later cleanup after the first apply. +################################################################################ + +# Platform Lambda log groups were auto-created by Lambda ("Never expire"). +# budget-enforcer has never logged, so it has no group yet and is created fresh. +import { + for_each = toset([ + "app-auth-sync", + "app-secret-rollout", + "apply-gate", + "ci-broker", + "compliance-reporter", + "cost-report", + "daily-cost-check", + "override-cleanup", + "password-set", + "plan-review", + "resource-tagger", + "securityhub-summary", + "slack-alert", + "team-provisioner", + ]) + to = module.lambdas.aws_cloudwatch_log_group.lambda[each.key] + id = "/aws/lambda/${var.project}-${each.key}" +} + +import { + to = module.identity.aws_cloudwatch_log_group.auth_claims + id = "/aws/lambda/${var.project}-auth-claims" +} diff --git a/terraform/platform/lambdas/log-groups.tf b/terraform/platform/lambdas/log-groups.tf new file mode 100644 index 0000000..c5bdb3e --- /dev/null +++ b/terraform/platform/lambdas/log-groups.tf @@ -0,0 +1,42 @@ +################################################################################ +# CloudWatch log groups for the platform Lambdas +# +# Lambda auto-creates /aws/lambda/ with "Never expire" when a function +# first logs. Declaring the groups here bounds retention and puts them under +# Terraform. Existing groups are adopted by the import blocks in +# terraform/platform/imports.tf. +# +# Every aws_lambda_function in this module must have its short name listed in +# local.platform_lambda_names (scripts/tests/test_lambda_log_groups.py checks). +################################################################################ + +locals { + platform_lambda_names = toset([ + "app-auth-sync", + "app-secret-rollout", + "apply-gate", + "budget-enforcer", + "ci-broker", + "compliance-reporter", + "cost-report", + "daily-cost-check", + "override-cleanup", + "password-set", + "plan-review", + "resource-tagger", + "securityhub-summary", + "slack-alert", + "team-provisioner", + ]) + + # 365 days: keeps a year of audit trail for apply-gate, ci-broker and + # password-set. Platform Lambda logs are ~80 MB in total, so cost is negligible. + platform_lambda_log_retention_days = 365 +} + +resource "aws_cloudwatch_log_group" "lambda" { + for_each = local.platform_lambda_names + + name = "/aws/lambda/${var.project}-${each.key}" + retention_in_days = local.platform_lambda_log_retention_days +} diff --git a/terraform/platform/main.tf b/terraform/platform/main.tf index c757fa4..5e860b6 100644 --- a/terraform/platform/main.tf +++ b/terraform/platform/main.tf @@ -10,7 +10,7 @@ # iam GitHub OIDC, CI roles, permission boundary, ECS execution role # compute ECS cluster, public ECR images # monitoring SNS topics, EventBridge rules, Config, GuardDuty, Security Hub -# cost-analytics CUR, Athena, Glue, billing alarms, account budget +# cost-analytics CUR, Athena, Glue, account budget # lambdas slack-alert, cost-report, daily-cost-check, auto-tagger # identity Cognito pools (Identity Center is in terraform/org/) # dns Route53 hosted zones for java.no, javabin.no/com, teknologihuset.no @@ -63,14 +63,13 @@ module "monitoring" { } module "cost_analytics" { - source = "./cost-analytics" - project = var.project - region = var.region - aws_account_id = var.aws_account_id - alerts_topic_arn = module.monitoring.alerts_topic_arn - org_boundary_arn = module.iam.org_boundary_arn - billing_alarm_threshold_usd = 200 - account_budget_usd = 500 + source = "./cost-analytics" + project = var.project + region = var.region + aws_account_id = var.aws_account_id + alerts_topic_arn = module.monitoring.alerts_topic_arn + org_boundary_arn = module.iam.org_boundary_arn + account_budget_usd = 600 providers = { aws = aws diff --git a/terraform/platform/monitoring/main.tf b/terraform/platform/monitoring/main.tf index b3db101..0962047 100644 --- a/terraform/platform/monitoring/main.tf +++ b/terraform/platform/monitoring/main.tf @@ -246,10 +246,29 @@ resource "aws_config_configuration_recorder" "main" { name = "${var.project}-recorder" role_arn = aws_iam_service_linked_role.config.arn + # Cost: Fargate task churn creates and deletes ENIs in the platform subnets, + # and every ENI change re-records the ENI, its VPC and subnet. In Sept-Oct + # 2026 that was ~7k configuration items/month (~$22), mostly from one + # crash-looping service. ENIs are NOT excluded: Security Hub EC2.180 (ENI + # source/destination check) evaluates AWS::EC2::NetworkInterface, and + # Config.1 fails if an enabled control's resource type is not recorded. + # Instead ENI, VPC and Subnet are recorded DAILY (one item per changed + # resource per day). Security groups stay continuous because SG changes are + # security-relevant. See docs/cost-management.md. recording_group { all_supported = true include_global_resource_types = true } + + recording_mode { + recording_frequency = "CONTINUOUS" + + recording_mode_override { + description = "Fargate ENI churn: daily is enough for ENIs and the VPC/subnet relationships they re-record" + resource_types = ["AWS::EC2::NetworkInterface", "AWS::EC2::VPC", "AWS::EC2::Subnet"] + recording_frequency = "DAILY" + } + } } resource "aws_config_delivery_channel" "main" {