Add monitoring + logging layer (audit Day 2: H-1/H-14/M-13) (#6)

- H-1: 15 CIS Section 4 metric filters (4.1-4.15) on the CloudTrail log group,
  each alarming to a new SSE SNS topic seahaven-cis-alarms (email to adam).
  ALARM-only actions per Sea Haven preference. 4.16 = Security Hub (Day 1).
- H-14: VPC flow logs (ALL traffic) on all 5 VPCs → hardened S3 bucket. Delivery
  bucket policy cross-reviewed; kept the AWS-required s3:x-amz-acl condition +
  logs:*:* source-ARN (cross-reviewer wrongly flagged these; verified against
  AWS flow-logs-s3-permissions docs), dropped the unneeded s3:ListBucket.
- M-13: SES configuration set seahaven-email-events capturing bounce/complaint/
  reject to CloudWatch for reputation visibility.

L-4 (log retention) and L-5 (alarm action) applied via CLI, documented in README.
This commit is contained in:
Adam Moussa 2026-06-02 15:16:24 -04:00 • committed by GitHub
parent 38d4a5753a
commit 3ba90ddc40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 412 additions and 3 deletions

View file

@ -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 The M-10 budget already provides cost alerting independent of that metric, so
this only affects the legacy `AWS-MonthlyBilling` CloudWatch alarm (L-8). 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 <the 13 groups>; 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) ## Roadmap (same stack)
Detective layer multi-region expansion (GuardDuty/Config/Security Hub beyond 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 us-east-1). Backup phase 2: expand past the phase-1 set via tag-based selection
group. Backup phase 2: expand past the phase-1 set via tag-based selection and and graduate the offsite vault to compliance mode.
graduate the offsite vault to compliance mode.
## Deploy ## Deploy

View file

@ -7,6 +7,9 @@ import * as cloudtrail from "aws-cdk-lib/aws-cloudtrail";
import { Construct } from "constructs"; import { Construct } from "constructs";
import { DetectiveControls } from "./detective-controls"; import { DetectiveControls } from "./detective-controls";
import { GovernanceToggles } from "./governance-toggles"; 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). * Account-level security baseline for Sea Haven (account 328440206208).
@ -143,6 +146,15 @@ export class AccountBaselineStack extends cdk.Stack {
alertEmail: props.budgetAlertEmail, 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("Project", "account-baseline");
cdk.Tags.of(this).add("Owner", "adam@seahavenind.com"); cdk.Tags.of(this).add("Owner", "adam@seahavenind.com");
cdk.Tags.of(this).add("Environment", "prod"); cdk.Tags.of(this).add("Environment", "prod");

197
lib/cis-monitoring.ts Normal file
View file

@ -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 });
}
}

108
lib/flow-logs.ts Normal file
View file

@ -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 });
}
}

50
lib/ses-monitoring.ts Normal file
View file

@ -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 });
}
}