From 5bc1ad58b2631111b9fa17d06267a1234d381695 Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Sat, 24 Oct 2026 15:38:28 +0200 Subject: [PATCH 1/2] chore(monitoring): tune GuardDuty and Security Hub to verified-benign patterns Archive filters for the team-provisioner Cognito sync and team SSO roles reading their own SSM params; disable inapplicable CIS/FSBP controls with reasons; suppress accepted RDS.2/RDS.46 (dbmoresleep) and ELB.18 (redirect-only listener). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/platform-modules.md | 19 ++ terraform/platform/ingress/outputs.tf | 5 + terraform/platform/main.tf | 9 +- .../platform/monitoring/detection-tuning.tf | 227 ++++++++++++++++++ terraform/platform/monitoring/variables.tf | 5 + 5 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 terraform/platform/monitoring/detection-tuning.tf diff --git a/docs/platform-modules.md b/docs/platform-modules.md index e8a2da0..4bf49de 100644 --- a/docs/platform-modules.md +++ b/docs/platform-modules.md @@ -85,6 +85,25 @@ SNS topics, EventBridge rules, Config, GuardDuty, Security Hub, account guardrai | `javabin-alert-dedup` DynamoDB | Deduplication table used by slack-alert Lambda | | Cost allocation tags | `aws_ce_cost_allocation_tag` resources activating 7 tags: team, service, repo, environment, managed-by, created-by, commit | +### Detection tuning + +`monitoring/detection-tuning.tf` holds every suppression, each with its reason in code. Review this list when a reason stops being true. + +| Kind | What | Why | +|------|------|-----| +| GuardDuty filter (archive) | `Impact:IAMUser/AnomalousBehavior` from `javabin-team-provisioner` on `cognito-idp` | Scheduled Cognito group sync by the team-provisioner Lambda | +| GuardDuty filter (archive) | `CredentialAccess:IAMUser/AnomalousBehavior`, API `GetParameter`, from `AWSReservedSSO_javabin-team-*` roles | The team permission set only allows SSM reads under `/javabin/apps//*`. Role names come from a data source; new teams are added on the next apply | +| Security Hub control disabled | CIS 1.2.0 CloudWatch.1-14, CloudTrail.5; FSBP CloudTrail.5 | No CloudTrail to CloudWatch Logs (cost). EventBridge rules and GuardDuty give the equivalent alerts | +| Security Hub control disabled | FSBP GuardDuty.7 | No EKS | +| Security Hub control disabled | FSBP Macie.1 | Not adopted (cost); revisit if PII lands in S3 | +| Security Hub control disabled | FSBP EC2.10, EC2.55-58, EC2.60 | No VPC interface endpoints (~$15/month each); traffic goes through NAT over TLS | +| Security Hub control disabled | FSBP EC2.172 | VPC block-public-access is incompatible with the public ALB and NAT | +| Security Hub control disabled | FSBP Inspector.1, Inspector.3 | Inspector EC2 and Lambda code scanning are off (legacy EB only; cost) | +| Security Hub automation rule (suppress) | RDS.2, RDS.46 on `dbmoresleep` | Intentionally public database; EC2.18/EC2.19 on its security group stay open | +| Security Hub automation rule (suppress) | ELB.18 on the platform ALB port-80 listener | Redirect-only listener | + +GuardDuty filters only affect new findings; archive existing matches once in the console. + ## lambdas 11 Lambda functions — see [lambda-functions.md](lambda-functions.md) for details. diff --git a/terraform/platform/ingress/outputs.tf b/terraform/platform/ingress/outputs.tf index f4bf480..dbd213e 100644 --- a/terraform/platform/ingress/outputs.tf +++ b/terraform/platform/ingress/outputs.tf @@ -22,3 +22,8 @@ output "route53_zone_id" { description = "Zone ID of the Route53 hosted zone" value = data.aws_route53_zone.main.zone_id } + +output "http_listener_arn" { + description = "ARN of the port-80 HTTP-to-HTTPS redirect listener" + value = aws_lb_listener.http_redirect.arn +} diff --git a/terraform/platform/main.tf b/terraform/platform/main.tf index ab0e7bd..c757fa4 100644 --- a/terraform/platform/main.tf +++ b/terraform/platform/main.tf @@ -55,10 +55,11 @@ module "compute" { } module "monitoring" { - source = "./monitoring" - project = var.project - region = var.region - aws_account_id = var.aws_account_id + source = "./monitoring" + project = var.project + region = var.region + aws_account_id = var.aws_account_id + alb_http_listener_arn = module.ingress.http_listener_arn } module "cost_analytics" { diff --git a/terraform/platform/monitoring/detection-tuning.tf b/terraform/platform/monitoring/detection-tuning.tf new file mode 100644 index 0000000..3700a35 --- /dev/null +++ b/terraform/platform/monitoring/detection-tuning.tf @@ -0,0 +1,227 @@ +################################################################################ +# Detection tuning — GuardDuty filters and Security Hub control/finding +# suppression for patterns verified as benign or as accepted risk. +# +# Every entry states why. docs/platform-modules.md ("Detection tuning") lists +# them all; review the list when the reason stops being true. +################################################################################ + +# ------------------------------------------------------------------ +# GuardDuty archive filters (apply to new findings only) +# ------------------------------------------------------------------ + +# The team-provisioner Lambda syncs Cognito group descriptions on a schedule +# (cognito-idp:UpdateGroup); GuardDuty flags it as Impact:IAMUser/ +# AnomalousBehavior. Only that type, that principal and that service. +resource "aws_guardduty_filter" "team_provisioner_cognito_sync" { + name = "team-provisioner-cognito-sync" + description = "Benign: team-provisioner Lambda syncs Cognito groups" + detector_id = aws_guardduty_detector.main.id + action = "ARCHIVE" + rank = 1 + + finding_criteria { + criterion { + field = "type" + equals = ["Impact:IAMUser/AnomalousBehavior"] + } + criterion { + field = "resource.accessKeyDetails.userName" + equals = ["${var.project}-team-provisioner"] + } + criterion { + field = "service.action.awsApiCallAction.serviceName" + equals = ["cognito-idp.amazonaws.com"] + } + } +} + +# Team SSO roles read their apps' SSM parameters (`javabin secret`, the +# console). The team permission set only allows ssm:GetParameter* on +# /javabin/apps/${aws:PrincipalTag/team}/*, so every such read is the team's +# own parameter. Other anomaly types for these roles still alert. +data "aws_iam_roles" "team_sso" { + name_regex = "^AWSReservedSSO_${var.project}-team-.+" + path_prefix = "/aws-reserved/sso.amazonaws.com/" +} + +resource "aws_guardduty_filter" "team_sso_own_ssm_params" { + # Role names carry an unpredictable suffix, so they come from IAM; skip the + # filter when no team role exists yet. New teams are picked up next apply. + count = length(data.aws_iam_roles.team_sso.names) > 0 ? 1 : 0 + + name = "team-sso-own-ssm-params" + description = "Benign: team SSO roles read SSM params under their own team path" + detector_id = aws_guardduty_detector.main.id + action = "ARCHIVE" + rank = 2 + + finding_criteria { + criterion { + field = "type" + equals = ["CredentialAccess:IAMUser/AnomalousBehavior"] + } + criterion { + field = "service.action.awsApiCallAction.api" + equals = ["GetParameter"] + } + criterion { + field = "resource.accessKeyDetails.userName" + equals = sort(tolist(data.aws_iam_roles.team_sso.names)) + } + } +} + +# ------------------------------------------------------------------ +# Security Hub controls disabled per standard +# ------------------------------------------------------------------ + +locals { + cis_1_2_arn = "arn:aws:securityhub:::ruleset/cis-aws-foundations-benchmark/v/1.2.0" + fsbp_1_0_arn = "arn:aws:securityhub:${var.region}::standards/aws-foundational-security-best-practices/v/1.0.0" + + reason_no_cw_logs = "CloudTrail is not sent to CloudWatch Logs (ingestion cost). Equivalent alerts come from EventBridge rules (iam-changes, console-login, resource-creation/modification, security-tampering) to javabin-security, and GuardDuty covers unauthorized API calls and root use." + + disabled_controls = merge( + # CIS 1.2.0 metric-filter alarms all need CloudTrail -> CloudWatch Logs. + { for n in range(1, 15) : "cis/CloudWatch.${n}" => { + standard = local.cis_1_2_arn + control = "CloudWatch.${n}" + reason = local.reason_no_cw_logs + } }, + { + "cis/CloudTrail.5" = { + standard = local.cis_1_2_arn + control = "CloudTrail.5" + reason = local.reason_no_cw_logs + } + "fsbp/CloudTrail.5" = { + standard = local.fsbp_1_0_arn + control = "CloudTrail.5" + reason = local.reason_no_cw_logs + } + "fsbp/GuardDuty.7" = { + standard = local.fsbp_1_0_arn + control = "GuardDuty.7" + reason = "No EKS clusters in the account." + } + "fsbp/Macie.1" = { + standard = local.fsbp_1_0_arn + control = "Macie.1" + reason = "Macie not adopted: cost not justified for current S3 data. Revisit if PII is stored in S3." + } + "fsbp/EC2.172" = { + standard = local.fsbp_1_0_arn + control = "EC2.172" + reason = "VPC block-public-access cannot be on: the public ALB and NAT gateway need the internet gateway." + } + "fsbp/Inspector.1" = { + standard = local.fsbp_1_0_arn + control = "Inspector.1" + reason = "Inspector EC2 scanning off: only legacy Elastic Beanstalk instances (not SSM-managed), being migrated to ECS." + } + "fsbp/Inspector.3" = { + standard = local.fsbp_1_0_arn + control = "Inspector.3" + reason = "Inspector Lambda code scanning off (cost). Lambda package scanning (Inspector.4) is on." + } + }, + # Interface endpoints cost ~$15/month each across two AZs; AWS API traffic + # leaves through NAT over TLS, which is acceptable here. + { for c in ["EC2.10", "EC2.55", "EC2.56", "EC2.57", "EC2.58", "EC2.60"] : "fsbp/${c}" => { + standard = local.fsbp_1_0_arn + control = c + reason = "No VPC interface endpoints (~$15/month each). AWS API traffic uses the NAT gateway over TLS." + } }, + ) +} + +resource "aws_securityhub_standards_control_association" "disabled" { + for_each = local.disabled_controls + + standards_arn = each.value.standard + security_control_id = each.value.control + association_status = "DISABLED" + updated_reason = each.value.reason + + depends_on = [aws_securityhub_account.main] +} + +# ------------------------------------------------------------------ +# Security Hub automation rules — suppress specific accepted findings +# ------------------------------------------------------------------ + +# moresleep's database is intentionally publicly reachable (public API with +# existing external clients; no architecture change during migration). +# Narrowing its security group is tracked separately, so EC2.18/EC2.19 on that +# group stay open on purpose. +resource "aws_securityhub_automation_rule" "moresleep_public_rds" { + rule_name = "${var.project}-accepted-moresleep-public-rds" + description = "dbmoresleep is intentionally publicly accessible (accepted risk)." + rule_order = 1 + rule_status = "ENABLED" + + criteria { + compliance_security_control_id { + comparison = "EQUALS" + value = "RDS.2" + } + compliance_security_control_id { + comparison = "EQUALS" + value = "RDS.46" + } + resource_id { + comparison = "EQUALS" + value = "arn:aws:rds:${var.region}:${var.aws_account_id}:db:dbmoresleep" + } + } + + actions { + type = "FINDING_FIELDS_UPDATE" + finding_fields_update { + workflow { + status = "SUPPRESSED" + } + note { + text = "Accepted risk: dbmoresleep is a public endpoint by design. Security group narrowing is tracked in the security audit (H3)." + updated_by = "${var.project}-platform-terraform" + } + } + } + + depends_on = [aws_securityhub_account.main] +} + +# The platform ALB's port-80 listener only redirects to HTTPS. +resource "aws_securityhub_automation_rule" "alb_http_redirect" { + rule_name = "${var.project}-accepted-alb-http-redirect" + description = "The platform ALB port-80 listener only redirects to HTTPS." + rule_order = 2 + rule_status = "ENABLED" + + criteria { + compliance_security_control_id { + comparison = "EQUALS" + value = "ELB.18" + } + resource_id { + comparison = "EQUALS" + value = var.alb_http_listener_arn + } + } + + actions { + type = "FINDING_FIELDS_UPDATE" + finding_fields_update { + workflow { + status = "SUPPRESSED" + } + note { + text = "False positive: redirect-only listener (HTTP 301 to HTTPS)." + updated_by = "${var.project}-platform-terraform" + } + } + } + + depends_on = [aws_securityhub_account.main] +} diff --git a/terraform/platform/monitoring/variables.tf b/terraform/platform/monitoring/variables.tf index f7430eb..6a9595f 100644 --- a/terraform/platform/monitoring/variables.tf +++ b/terraform/platform/monitoring/variables.tf @@ -12,3 +12,8 @@ variable "aws_account_id" { description = "AWS account ID" type = string } + +variable "alb_http_listener_arn" { + description = "ARN of the platform ALB's port-80 redirect listener (Security Hub ELB.18 suppression)" + type = string +} From b4ae6a3c1a7e00ac5e0e9e5b7d799dc84c3a4b57 Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Sat, 24 Oct 2026 16:01:11 +0200 Subject: [PATCH 2/2] fix(monitoring): drop the team-SSO GetParameter GuardDuty filter The team permission set does not limit SSM reads to the team's path, and the archived finding type is what stolen SSO credentials produce. One of the two live findings came from Hungary. Leave it alerting. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/platform-modules.md | 3 +- .../platform/monitoring/detection-tuning.tf | 40 +++---------------- 2 files changed, 7 insertions(+), 36 deletions(-) diff --git a/docs/platform-modules.md b/docs/platform-modules.md index 4bf49de..5405329 100644 --- a/docs/platform-modules.md +++ b/docs/platform-modules.md @@ -92,7 +92,6 @@ SNS topics, EventBridge rules, Config, GuardDuty, Security Hub, account guardrai | Kind | What | Why | |------|------|-----| | GuardDuty filter (archive) | `Impact:IAMUser/AnomalousBehavior` from `javabin-team-provisioner` on `cognito-idp` | Scheduled Cognito group sync by the team-provisioner Lambda | -| GuardDuty filter (archive) | `CredentialAccess:IAMUser/AnomalousBehavior`, API `GetParameter`, from `AWSReservedSSO_javabin-team-*` roles | The team permission set only allows SSM reads under `/javabin/apps//*`. Role names come from a data source; new teams are added on the next apply | | Security Hub control disabled | CIS 1.2.0 CloudWatch.1-14, CloudTrail.5; FSBP CloudTrail.5 | No CloudTrail to CloudWatch Logs (cost). EventBridge rules and GuardDuty give the equivalent alerts | | Security Hub control disabled | FSBP GuardDuty.7 | No EKS | | Security Hub control disabled | FSBP Macie.1 | Not adopted (cost); revisit if PII lands in S3 | @@ -104,6 +103,8 @@ SNS topics, EventBridge rules, Config, GuardDuty, Security Hub, account guardrai GuardDuty filters only affect new findings; archive existing matches once in the console. +Team SSO roles' `CredentialAccess:IAMUser/AnomalousBehavior` on `ssm:GetParameter` is deliberately not filtered. The `javabin-team-{team}` permission set is `Action "*"` scoped by `StringEqualsIfExists aws:ResourceTag/team`, so untagged parameters are readable too, and this is the finding a stolen SSO session produces. Check the source country and confirm with the user before archiving one. + ## lambdas 11 Lambda functions — see [lambda-functions.md](lambda-functions.md) for details. diff --git a/terraform/platform/monitoring/detection-tuning.tf b/terraform/platform/monitoring/detection-tuning.tf index 3700a35..6a2e084 100644 --- a/terraform/platform/monitoring/detection-tuning.tf +++ b/terraform/platform/monitoring/detection-tuning.tf @@ -36,41 +36,11 @@ resource "aws_guardduty_filter" "team_provisioner_cognito_sync" { } } -# Team SSO roles read their apps' SSM parameters (`javabin secret`, the -# console). The team permission set only allows ssm:GetParameter* on -# /javabin/apps/${aws:PrincipalTag/team}/*, so every such read is the team's -# own parameter. Other anomaly types for these roles still alert. -data "aws_iam_roles" "team_sso" { - name_regex = "^AWSReservedSSO_${var.project}-team-.+" - path_prefix = "/aws-reserved/sso.amazonaws.com/" -} - -resource "aws_guardduty_filter" "team_sso_own_ssm_params" { - # Role names carry an unpredictable suffix, so they come from IAM; skip the - # filter when no team role exists yet. New teams are picked up next apply. - count = length(data.aws_iam_roles.team_sso.names) > 0 ? 1 : 0 - - name = "team-sso-own-ssm-params" - description = "Benign: team SSO roles read SSM params under their own team path" - detector_id = aws_guardduty_detector.main.id - action = "ARCHIVE" - rank = 2 - - finding_criteria { - criterion { - field = "type" - equals = ["CredentialAccess:IAMUser/AnomalousBehavior"] - } - criterion { - field = "service.action.awsApiCallAction.api" - equals = ["GetParameter"] - } - criterion { - field = "resource.accessKeyDetails.userName" - equals = sort(tolist(data.aws_iam_roles.team_sso.names)) - } - } -} +# Deliberately no filter for team SSO roles' CredentialAccess:IAMUser/ +# AnomalousBehavior on ssm:GetParameter. The team permission set is +# Action "*" scoped by StringEqualsIfExists aws:ResourceTag/team, so it does +# not limit reads to the team's own path, and this finding type is exactly +# what a stolen SSO session reading secrets produces. Triage it by hand. # ------------------------------------------------------------------ # Security Hub controls disabled per standard