import * as cdk from "aws-cdk-lib"; import * as logs from "aws-cdk-lib/aws-logs"; import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch"; import * as cwactions from "aws-cdk-lib/aws-cloudwatch-actions"; import * as sns from "aws-cdk-lib/aws-sns"; import * as subscriptions from "aws-cdk-lib/aws-sns-subscriptions"; import * as kms from "aws-cdk-lib/aws-kms"; import { Construct } from "constructs"; /** * CIS AWS Foundations Benchmark v3.0 Section 4 — Monitoring (audit H-1). * * 15 metric filters on the account CloudTrail log group, each backed by a * CloudWatch alarm that notifies a dedicated SNS topic. Closes CIS 4.1–4.15. * (4.16 "Security Hub enabled" is not a metric filter — done Day 1, H-4.) * * Alarms fire on ALARM only (no OK/recovery actions) per Sea Haven preference. * * The trail log group is injected via props (INFRA-19). The previous approach * imported the group by a hardcoded CDK-generated name constant — if the Trail * or log group was ever recreated the generated suffix would change and all 15 * filters would silently detach. The caller now creates an explicit LogGroup with * a stable name and passes the CDK object here. */ interface CisControl { readonly id: string; readonly metricName: string; readonly pattern: string; readonly description: string; // Optional alarm-sensitivity override. Defaults to 1/1 (page on a single // breaching 5-min period) which suits low-frequency security signals. Raise // for noisy high-volume metrics where one-off breaches are expected. readonly evaluationPeriods?: number; readonly datapointsToAlarm?: number; } const CIS_CONTROLS: CisControl[] = [ { id: "UnauthorizedApiCalls", metricName: "UnauthorizedAPICalls", // The previous pattern relied on `&&` binding tighter than `||`, so the // sourceIPAddress/HeadBucket exclusions applied ONLY to the AccessDenied // branch, and the sole IP exclusion was delivery.logs.amazonaws.com. That // left the filter counting the dominant source of benign noise: AccessDenied // /*UnauthorizedOperation records generated by AWS services acting on our // behalf — CloudFormation deploy/drift describe-scans (cloudformation. and // hooks.cloudformation.amazonaws.com), the AWS Config recorder, etc. On // 2026-07-07 a single CFN run emitted 100+ such denials in 15 minutes and // flapped this alarm; it transitioned OK<->ALARM 15 times in 30 days, all // benign, drowning the CIS 4.1 signal in email noise (alert fatigue). // // Fix: (1) group both error codes so the exclusions apply to the whole // filter (not just the AccessDenied branch), and (2) drop only the SPECIFIC // benign service sources that actually flap this account — CloudFormation // (deploy/drift describe-scans, `cloudformation.` and `hooks.cloudformation.`) // and the Config recorder (`config.`) — plus the pre-existing delivery.logs // exclusion. // // SECURITY NOTE (sh-security-review 2026-07-07): an earlier revision excluded // ALL `*.amazonaws.com` source hosts. That was rejected — a confirmed MEDIUM // blind spot: denials driven through OTHER services (SSM Automation, Step // Functions, Lambda, etc.) are recorded with that service's host as the // sourceIPAddress, so a blanket exclusion would hide service-proxied // privesc/recon attempts. Scoping to the named benign hosts keeps every other // service-proxied denial in scope. Residual (accepted): denials proxied // specifically through CloudFormation/Config are still excluded — that path // requires near-admin privilege (cloudformation:CreateStack + iam:PassRole), // any *successful* change still trips the other CIS 4.x alarms, and GuardDuty // provides defence-in-depth. Direct console/CLI/credential denials always // present a routable IP and are always counted. pattern: '{ (($.errorCode = "*UnauthorizedOperation") || ($.errorCode = "AccessDenied*")) && ($.sourceIPAddress != "*cloudformation.amazonaws.com") && ($.sourceIPAddress != "config.amazonaws.com") && ($.sourceIPAddress != "delivery.logs.amazonaws.com") && ($.eventName != "HeadBucket") }', description: "CIS 4.1 — unauthorized API calls", // M-of-N with N > M so a single quiet 5-min window cannot reset detection. // Before #36, false positives from CFN/Config-proxied denials forced a // conservative 3/3 consecutive; now that #36 scoped the metric-filter // exclusion to those benign sources, we can widen the evaluation window // while keeping the same alarm threshold. An attacker pacing denied calls to // leave every third window empty would evade a 3/3 alarm but still trips a // 3/6 — three breaching windows in any rolling 30-min span fire the alarm. evaluationPeriods: 6, datapointsToAlarm: 3, }, { id: "ConsoleSigninNoMfa", metricName: "ConsoleSigninWithoutMFA", pattern: '{ ($.eventName = "ConsoleLogin") && ($.additionalEventData.MFAUsed != "Yes") && ($.userIdentity.type = "IAMUser") && ($.responseElements.ConsoleLogin = "Success") }', description: "CIS 4.2 — console sign-in without MFA", }, { id: "RootAccountUsage", metricName: "RootAccountUsage", pattern: '{ $.userIdentity.type = "Root" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != "AwsServiceEvent" }', description: "CIS 4.3 — root account usage", }, { id: "IamPolicyChanges", metricName: "IAMPolicyChanges", pattern: "{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}", description: "CIS 4.4 — IAM policy changes", }, { id: "CloudTrailConfigChanges", metricName: "CloudTrailConfigChanges", pattern: "{ ($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging) }", description: "CIS 4.5 — CloudTrail configuration changes", }, { id: "ConsoleAuthFailures", metricName: "ConsoleAuthenticationFailures", pattern: '{ ($.eventName = ConsoleLogin) && ($.errorMessage = "Failed authentication") }', description: "CIS 4.6 — console authentication failures", }, { id: "CmkDisableOrDelete", metricName: "CMKDisableOrScheduledDelete", pattern: "{ ($.eventSource = kms.amazonaws.com) && (($.eventName = DisableKey) || ($.eventName = ScheduleKeyDeletion)) }", description: "CIS 4.7 — disabling or scheduled deletion of CMKs", }, { id: "S3BucketPolicyChanges", metricName: "S3BucketPolicyChanges", pattern: "{ ($.eventSource = s3.amazonaws.com) && (($.eventName = PutBucketAcl) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketReplication) || ($.eventName = DeleteBucketPolicy) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketReplication)) }", description: "CIS 4.8 — S3 bucket policy changes", }, { id: "ConfigChanges", metricName: "AWSConfigChanges", pattern: "{ ($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder)) }", description: "CIS 4.9 — AWS Config configuration changes", }, { id: "SecurityGroupChanges", metricName: "SecurityGroupChanges", pattern: "{ ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) }", description: "CIS 4.10 — security group changes", }, { id: "NaclChanges", metricName: "NetworkACLChanges", pattern: "{ ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }", description: "CIS 4.11 — network ACL changes", }, { id: "NetworkGatewayChanges", metricName: "NetworkGatewayChanges", pattern: "{ ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) }", description: "CIS 4.12 — network gateway changes", }, { id: "RouteTableChanges", metricName: "RouteTableChanges", pattern: "{ ($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable) }", description: "CIS 4.13 — route table changes", }, { id: "VpcChanges", metricName: "VPCChanges", pattern: "{ ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }", description: "CIS 4.14 — VPC changes", }, { id: "OrganizationsChanges", metricName: "OrganizationsChanges", pattern: '{ ($.eventSource = organizations.amazonaws.com) && (($.eventName = "AcceptHandshake") || ($.eventName = "AttachPolicy") || ($.eventName = "CreateAccount") || ($.eventName = "CreateOrganizationalUnit") || ($.eventName = "CreatePolicy") || ($.eventName = "DeclineHandshake") || ($.eventName = "DeleteOrganization") || ($.eventName = "DeleteOrganizationalUnit") || ($.eventName = "DeletePolicy") || ($.eventName = "DetachPolicy") || ($.eventName = "DisablePolicyType") || ($.eventName = "EnablePolicyType") || ($.eventName = "InviteAccountToOrganization") || ($.eventName = "LeaveOrganization") || ($.eventName = "MoveAccount") || ($.eventName = "RemoveAccountFromOrganization") || ($.eventName = "UpdatePolicy") || ($.eventName = "UpdateOrganizationalUnit")) }', description: "CIS 4.15 — AWS Organizations changes", }, ]; export interface CisMonitoringProps { /** Email subscribed to the CIS alarm topic. */ readonly alarmEmail: string; /** * The CloudTrail CloudWatch Logs group. Must be the explicit stable-named * LogGroup created in account-baseline-stack.ts (INFRA-19) — not an imported * reference, so the metric filters are bound to the CDK object rather than a * hardcoded generated name. */ readonly trailLogGroup: logs.ILogGroup; } export class CisMonitoring extends Construct { constructor(scope: Construct, id: string, props: CisMonitoringProps) { super(scope, id); // Customer-managed key for alarm topics (audit L-14). The AWS-managed // alias/aws/sns key CANNOT be used here: its key policy can't grant // cloudwatch.amazonaws.com, so CloudWatch alarms silently fail to publish // to topics it encrypts — which is exactly what these topics receive. // Also used by the unmanaged site-alerts topic (set via CLI; ARN output below). const alarmTopicKey = new kms.Key(this, "AlarmTopicKey", { alias: "seahaven-alarm-topics", description: "SSE for SNS alarm topics; grants CloudWatch alarms publish-side usage", enableKeyRotation: true, }); alarmTopicKey.addToResourcePolicy( new cdk.aws_iam.PolicyStatement({ sid: "AllowCloudWatchAlarmsUse", principals: [new cdk.aws_iam.ServicePrincipal("cloudwatch.amazonaws.com")], actions: ["kms:GenerateDataKey*", "kms:Decrypt", "kms:DescribeKey"], resources: ["*"], conditions: { StringEquals: { "aws:SourceAccount": cdk.Stack.of(this).account }, }, }) ); new cdk.CfnOutput(this, "AlarmTopicKeyArn", { value: alarmTopicKey.keyArn }); // Dedicated topic for security/CIS alarms (audit H-1, L-14). const topic = new sns.Topic(this, "CisAlarmTopic", { topicName: "seahaven-cis-alarms", displayName: "Sea Haven CIS / security alarms", masterKey: alarmTopicKey, }); topic.addSubscription(new subscriptions.EmailSubscription(props.alarmEmail)); const logGroup = props.trailLogGroup; for (const c of CIS_CONTROLS) { const mf = new logs.MetricFilter(this, `${c.id}Filter`, { logGroup, filterPattern: logs.FilterPattern.literal(c.pattern), metricNamespace: "CISBenchmark", metricName: c.metricName, metricValue: "1", defaultValue: 0, }); const alarm = mf .metric({ statistic: "Sum", period: cdk.Duration.minutes(5), }) .createAlarm(this, `${c.id}Alarm`, { alarmName: `cis-${c.metricName}`, alarmDescription: c.description, threshold: 1, comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, evaluationPeriods: c.evaluationPeriods ?? 1, // Omitted (undefined) for default 1/1 controls so their templates are // untouched; CloudWatch defaults datapointsToAlarm to evaluationPeriods. datapointsToAlarm: c.datapointsToAlarm ?? c.evaluationPeriods, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); // ALARM-only notification (no OK/recovery action) per Sea Haven preference. alarm.addAlarmAction(new cwactions.SnsAction(topic)); } new cdk.CfnOutput(this, "CisAlarmTopicArn", { value: topic.topicArn }); } }