seahaven-org-baseline/lib/cis-monitoring.ts
Adam Moussa 993702f421
Replace aws/sns key with CMK on alarm topics (#13)
Audit L-14, plus a latent Day-2 bug: seahaven-cis-alarms was
encrypted with the AWS-managed alias/aws/sns key, whose policy cannot
grant cloudwatch.amazonaws.com - CloudWatch alarms silently fail to
publish to topics it encrypts. All 15 CIS alarms would have fired
into the void.

New customer-managed key (rotation on) grants CloudWatch
GenerateDataKey*/Decrypt/DescribeKey scoped by SourceAccount. The
unmanaged site-alerts topic now uses the same key (set via CLI).

Cross-reviewed: no BLOCKs. Verified: forced ALARM on the payroll DLQ
alarm published successfully through the encrypted site-alerts.
2026-06-03 15:33:52 -04:00

220 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 account CloudTrail (C-1) delivers to this CloudWatch Logs group. It is
// created by the L2 cloudtrail.Trail in account-baseline-stack.ts; we import it
// by name rather than replace it, so the live audit trail is never disrupted.
// Stable as long as the Trail is not recreated.
const TRAIL_LOG_GROUP_NAME =
"seahaven-account-baseline-TrailLogGroup4CBE3AF5-e7hMDCzj8e4d";
interface CisControl {
readonly id: string;
readonly metricName: string;
readonly pattern: string;
readonly description: string;
}
const CIS_CONTROLS: CisControl[] = [
{
id: "UnauthorizedApiCalls",
metricName: "UnauthorizedAPICalls",
pattern:
'{ ($.errorCode = "*UnauthorizedOperation") || ($.errorCode = "AccessDenied*") && ($.sourceIPAddress != "delivery.logs.amazonaws.com") && ($.eventName != "HeadBucket") }',
description: "CIS 4.1 — unauthorized API calls",
},
{
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;
}
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 = logs.LogGroup.fromLogGroupName(
this,
"TrailLogGroup",
TRAIL_LOG_GROUP_NAME
);
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: 1,
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 });
}
}