diff --git a/README.md b/README.md index 9b12be5..cac0b15 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,54 @@ Billing Alerts* under Billing → Billing preferences; there is no public API/CL The M-10 budget already provides cost alerting independent of that metric, so this only affects the legacy `AWS-MonthlyBilling` CloudWatch alarm (L-8). +### Monitoring + logging (audit Day 2) + +| Resource | Logical ID | Finding | Notes | +|---|---|---|---| +| CIS metric filters + alarms | `CisMonitoring/*` | H-1 | 15 filters (CIS 4.1–4.15) on the CloudTrail log group, each with an alarm → `seahaven-cis-alarms`. ALARM-only actions (no OK). 4.16 = Security Hub (Day 1) | +| CIS alarm topic | `seahaven-cis-alarms` | H-1 | SNS, SSE (`alias/aws/sns`), email sub to adam@seahavenind.com | +| VPC flow logs | `FlowLogs/FlowLog0..4` | H-14 | ALL traffic on all 5 VPCs → S3 | +| Flow-logs bucket | `seahaven-vpc-flow-logs-328440206208` | H-14 | Private, SSE-S3, TLS-only, Glacier @90d / expire @365d; delivery bucket policy cross-reviewed | +| SES config set | `seahaven-email-events` | M-13 | Bounce/complaint/reject → CloudWatch metrics for reputation visibility | + +**H-1 log group:** the metric filters attach to the existing CloudTrail +CloudWatch Logs group by name (`seahaven-account-baseline-TrailLogGroup4CBE3AF5-…`), +imported read-only so the live audit trail is never replaced. Stable unless the +Trail is recreated. + +**H-14 bucket policy note:** the flow-logs delivery policy keeps +`s3:x-amz-acl=bucket-owner-full-control` and the `arn:aws:logs:…:*` source-ARN +wildcard — both are required by AWS's documented flow-logs-to-S3 policy +(`flow-logs-s3-permissions.html`). A cross-review suggested dropping them; that +was rejected as it would break delivery. `s3:ListBucket` was dropped (not needed). + +**M-13 follow-up:** associate `seahaven-email-events` as the default config set +on the live sending identities to capture events from existing senders: + +```bash +aws sesv2 put-email-identity-configuration-set-attributes \ + --email-identity int.seahaven.com --configuration-set-name seahaven-email-events +``` + +### Log-group retention + alarm wiring (audit L-4, L-5) + +Applied via CLI (auto-created groups spread across stacks; one alarm in another +stack). Applied 2026-06-02. + +```bash +# L-4 90-day retention on the 13 never-expire log groups (CodeBuild + CDK helpers) +for lg in ; do aws logs put-retention-policy --log-group-name "$lg" --retention-in-days 90; done + +# L-5 wire the actionless forgejo backup-verification alarm to site-alerts +aws cloudwatch put-metric-alarm --alarm-name forgejo-backup-verification-errors \ + --alarm-actions arn:aws:sns:us-east-1:328440206208:site-alerts # (preserve existing alarm config) +``` + ## Roadmap (same stack) Detective layer multi-region expansion (GuardDuty/Config/Security Hub beyond -us-east-1). H-1: CIS Section 4 metric filters/alarms onto the CloudTrail log -group. Backup phase 2: expand past the phase-1 set via tag-based selection and -graduate the offsite vault to compliance mode. +us-east-1). Backup phase 2: expand past the phase-1 set via tag-based selection +and graduate the offsite vault to compliance mode. ## Deploy diff --git a/lib/account-baseline-stack.ts b/lib/account-baseline-stack.ts index f6fab2a..4f8c4a4 100644 --- a/lib/account-baseline-stack.ts +++ b/lib/account-baseline-stack.ts @@ -7,6 +7,9 @@ import * as cloudtrail from "aws-cdk-lib/aws-cloudtrail"; import { Construct } from "constructs"; import { DetectiveControls } from "./detective-controls"; import { GovernanceToggles } from "./governance-toggles"; +import { CisMonitoring } from "./cis-monitoring"; +import { FlowLogs } from "./flow-logs"; +import { SesMonitoring } from "./ses-monitoring"; /** * Account-level security baseline for Sea Haven (account 328440206208). @@ -143,6 +146,15 @@ export class AccountBaselineStack extends cdk.Stack { alertEmail: props.budgetAlertEmail, }); + // ── Day 2 monitoring + logging ── + // CIS Section 4 metric filters/alarms (H-1), VPC flow logs (H-14), + // SES bounce/complaint config set (M-13). + new CisMonitoring(this, "CisMonitoring", { + alarmEmail: props.budgetAlertEmail, + }); + new FlowLogs(this, "FlowLogs"); + new SesMonitoring(this, "SesMonitoring"); + cdk.Tags.of(this).add("Project", "account-baseline"); cdk.Tags.of(this).add("Owner", "adam@seahavenind.com"); cdk.Tags.of(this).add("Environment", "prod"); diff --git a/lib/cis-monitoring.ts b/lib/cis-monitoring.ts new file mode 100644 index 0000000..0939149 --- /dev/null +++ b/lib/cis-monitoring.ts @@ -0,0 +1,197 @@ +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); + + // Dedicated topic for security/CIS alarms, encrypted with the AWS-managed + // SNS key (also clears audit L-14 for this topic). + const topic = new sns.Topic(this, "CisAlarmTopic", { + topicName: "seahaven-cis-alarms", + displayName: "Sea Haven CIS / security alarms", + masterKey: kms.Alias.fromAliasName(this, "SnsKey", "alias/aws/sns"), + }); + 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 }); + } +} diff --git a/lib/flow-logs.ts b/lib/flow-logs.ts new file mode 100644 index 0000000..75205d6 --- /dev/null +++ b/lib/flow-logs.ts @@ -0,0 +1,108 @@ +import * as cdk from "aws-cdk-lib"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as ec2 from "aws-cdk-lib/aws-ec2"; +import { Construct } from "constructs"; + +/** + * VPC flow logs for every VPC, delivered to a hardened S3 bucket (audit H-14, + * CIS 3.9/5.6). S3 destination (not CloudWatch Logs) for cost — query + * forensically via Athena. ALL traffic (accept + reject). + * + * S3 delivery needs no IAM role; instead the bucket policy grants the + * `delivery.logs.amazonaws.com` service principal write access, scoped to this + * account. That bucket policy is the Day 2 cross-review item. + */ + +// All 5 VPCs in 328440206208 / us-east-1 (4 custom + default), audit Agent 7. +const VPC_IDS = [ + "vpc-061d66990b6a4d1fb", + "vpc-0542a9e934b417d23", + "vpc-062d200c68bd4ca0e", + "vpc-0d3d4b67bd0cf8a68", + "vpc-02c10a89d66f6f9b8", +]; + +export class FlowLogs extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + + const stack = cdk.Stack.of(this); + + const bucket = new s3.Bucket(this, "FlowLogsBucket", { + bucketName: `seahaven-vpc-flow-logs-${stack.account}`, + encryption: s3.BucketEncryption.S3_MANAGED, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + enforceSSL: true, + versioned: false, + lifecycleRules: [ + { + id: "transition-and-expire", + transitions: [ + { + storageClass: s3.StorageClass.GLACIER, + transitionAfter: cdk.Duration.days(90), + }, + ], + expiration: cdk.Duration.days(365), + abortIncompleteMultipartUploadAfter: cdk.Duration.days(7), + }, + ], + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + + // Log-delivery service permissions (scoped to this account) — the standard + // VPC-flow-logs-to-S3 bucket policy. + bucket.addToResourcePolicy( + new iam.PolicyStatement({ + sid: "AWSLogDeliveryWrite", + effect: iam.Effect.ALLOW, + principals: [new iam.ServicePrincipal("delivery.logs.amazonaws.com")], + actions: ["s3:PutObject"], + resources: [bucket.arnForObjects(`AWSLogs/${stack.account}/*`)], + conditions: { + StringEquals: { + "s3:x-amz-acl": "bucket-owner-full-control", + "aws:SourceAccount": stack.account, + }, + ArnLike: { + "aws:SourceArn": `arn:${stack.partition}:logs:${stack.region}:${stack.account}:*`, + }, + }, + }) + ); + bucket.addToResourcePolicy( + new iam.PolicyStatement({ + sid: "AWSLogDeliveryAclCheck", + effect: iam.Effect.ALLOW, + principals: [new iam.ServicePrincipal("delivery.logs.amazonaws.com")], + // AWS's documented flow-logs-to-S3 policy uses GetBucketAcl only + // (verified against flow-logs-s3-permissions.html); ListBucket is not + // needed and would be over-permissioned. + actions: ["s3:GetBucketAcl"], + resources: [bucket.bucketArn], + conditions: { + StringEquals: { "aws:SourceAccount": stack.account }, + ArnLike: { + "aws:SourceArn": `arn:${stack.partition}:logs:${stack.region}:${stack.account}:*`, + }, + }, + }) + ); + + VPC_IDS.forEach((vpcId, i) => { + const flowLog = new ec2.CfnFlowLog(this, `FlowLog${i}`, { + resourceId: vpcId, + resourceType: "VPC", + trafficType: "ALL", + logDestinationType: "s3", + logDestination: bucket.bucketArn, + maxAggregationInterval: 600, + tags: [{ key: "Name", value: `flow-log-${vpcId}` }], + }); + flowLog.node.addDependency(bucket.policy!); + }); + + new cdk.CfnOutput(this, "FlowLogsBucketName", { value: bucket.bucketName }); + } +} diff --git a/lib/ses-monitoring.ts b/lib/ses-monitoring.ts new file mode 100644 index 0000000..7e92e82 --- /dev/null +++ b/lib/ses-monitoring.ts @@ -0,0 +1,50 @@ +import * as cdk from "aws-cdk-lib"; +import * as ses from "aws-cdk-lib/aws-ses"; +import { Construct } from "constructs"; + +/** + * SES configuration set capturing bounce/complaint events (audit M-13). + * + * Gives reputation visibility beyond the suppression list by emitting bounce, + * complaint, and reject events to CloudWatch metrics (dimensioned by config + * set). Associating this set as the default on the live sending identities is a + * follow-up CLI step (documented in the README) — creating it here does not + * change current sending behaviour. + */ +export class SesMonitoring extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + + const configSetName = "seahaven-email-events"; + + const configSet = new ses.CfnConfigurationSet(this, "ConfigSet", { + name: configSetName, + reputationOptions: { reputationMetricsEnabled: true }, + }); + + const eventDest = new ses.CfnConfigurationSetEventDestination( + this, + "BounceComplaintDest", + { + configurationSetName: configSetName, + eventDestination: { + name: "bounce-complaint-cw", + enabled: true, + matchingEventTypes: ["bounce", "complaint", "reject"], + cloudWatchDestination: { + dimensionConfigurations: [ + { + defaultDimensionValue: "none", + dimensionName: "ses:configuration-set", + dimensionValueSource: "messageTag", + }, + ], + }, + }, + } + ); + eventDest.node.addDependency(configSet); + + new cdk.CfnOutput(this, "SesConfigSetName", { value: configSetName }); + } +}