seahaven-org-baseline/lib/account-baseline-stack.ts
Adam Moussa cd82b49f17
Stabilize CloudTrail log group name to prevent filter detachment (#23)
Previously the L2 cloudtrail.Trail auto-created a log group with a
CDK-generated hash suffix in its name. The 15 CIS Section 4 metric
filters in CisMonitoring imported that group by the hardcoded generated
name. If the Trail or log group was ever recreated the suffix changes
and all 15 filters would silently detach with no error, leaving the
account unmonitored.

Replace with an explicit logs.LogGroup named
seahaven-account-baseline-trail-logs (stable, no hash suffix) with
RemovalPolicy.RETAIN. Pass the CDK object — not a name constant — to
the Trail via cloudWatchLogGroup and forward it to CisMonitoring via a
new trailLogGroup prop. All 15 filters now reference the CDK object so
they can never drift from the group the Trail actually delivers to.

The old auto-named log group is orphaned by this deploy (CloudFormation
loses track of it and does not delete it). Historical audit logs in the
old group remain accessible in CloudWatch under the old name; no audit
history is destroyed.

Refs: INFRA-19
2026-06-10 14:44:33 -04:00

249 lines
11 KiB
TypeScript

import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as kms from "aws-cdk-lib/aws-kms";
import * as iam from "aws-cdk-lib/aws-iam";
import * as logs from "aws-cdk-lib/aws-logs";
import * as cloudtrail from "aws-cdk-lib/aws-cloudtrail";
import { Construct } from "constructs";
import { LogsKey } from "./logs-key";
import { DetectiveControls } from "./detective-controls";
import { GovernanceToggles } from "./governance-toggles";
import { BedrockLogging } from "./bedrock-logging";
import { CisMonitoring } from "./cis-monitoring";
import { FlowLogs } from "./flow-logs";
import { SesMonitoring } from "./ses-monitoring";
import { AppWebAcl } from "./web-acl";
/**
* Account-level security baseline for Sea Haven (account 328440206208).
*
* First resident: a multi-region CloudTrail with log-file validation, KMS
* encryption, an Object-Lock'd S3 log bucket, and CloudWatch Logs delivery.
* Closes audit finding C-1 and CIS 3.1/3.2/3.4/3.6/3.7 (+3.8 via key rotation),
* and provides the CloudWatch Logs group that the CIS Section 4 metric filters
* (H-1) attach to.
*
* Future residents (same stack): AWS Config (H-2), GuardDuty (H-3),
* Security Hub (H-4), IAM Access Analyzer (M-5), Inspector2 (M-6).
*/
export interface AccountBaselineStackProps extends cdk.StackProps {
/** Monthly cost budget ceiling in USD (M-10). */
readonly monthlyBudgetUsd: number;
/** Email for budget threshold alerts (M-10). */
readonly budgetAlertEmail: string;
}
export class AccountBaselineStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: AccountBaselineStackProps) {
super(scope, id, props);
const trailName = "seahaven-org-trail";
// AWS Organizations org id (o-9kufuzz6b4). CloudTrail org trusted-access is
// already enabled on the management account; promoting this trail to an org
// trail (INFRA-73) makes it collect member-account events into this bucket.
const orgId = "o-9kufuzz6b4";
// Static trail ARN (built from name, not trail.trailArn) so the key policy
// does not create a circular dependency with the Trail resource.
const trailArn = cdk.Arn.format(
{ service: "cloudtrail", resource: "trail", resourceName: trailName },
this
);
// ── KMS CMK ── encrypts CloudTrail log files (CIS 3.7); rotation = CIS 3.8.
const trailKey = new kms.Key(this, "TrailKey", {
alias: "cloudtrail-logs",
description: "Encrypts CloudTrail log files for the account-wide trail",
enableKeyRotation: true,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// The L2 Trail construct does NOT grant the CloudTrail service principal use
// of a customer-provided key, so delivery of encrypted logs would fail.
// Grant it explicitly, scoped to this account's trail via SourceArn and the
// CloudTrail encryption context. (Caught by cross-review 2026-05-29.)
trailKey.addToResourcePolicy(
new iam.PolicyStatement({
sid: "AllowCloudTrailEncrypt",
effect: iam.Effect.ALLOW,
principals: [new iam.ServicePrincipal("cloudtrail.amazonaws.com")],
actions: ["kms:GenerateDataKey*"],
resources: ["*"],
conditions: {
StringEquals: { "aws:SourceArn": trailArn },
StringLike: {
"kms:EncryptionContext:aws:cloudtrail:arn": `arn:${this.partition}:cloudtrail:*:${this.account}:trail/*`,
},
},
})
);
// INFRA-73 (org trail): member accounts deliver their CloudTrail events to
// this CMK-encrypted bucket, so the CloudTrail service principal must be able
// to GenerateDataKey using each member trail's own encryption context.
//
// Cross-review BLOCK (GPT-4.1): the SourceArn must NOT be pinned to the
// management account 328440206208 — org-trail shadow trails in member
// accounts present their OWN account id in both the SourceArn and the
// encryption-context arn, so pinning to the management account would silently
// block all member-account delivery. Both are wildcarded across accounts and
// the statement is org-scoped by aws:PrincipalOrgID so only accounts in
// o-9kufuzz6b4 — not arbitrary CloudTrail principals — can use the key.
trailKey.addToResourcePolicy(
new iam.PolicyStatement({
sid: "AllowOrgMemberCloudTrailEncrypt",
effect: iam.Effect.ALLOW,
principals: [new iam.ServicePrincipal("cloudtrail.amazonaws.com")],
actions: ["kms:GenerateDataKey*", "kms:DescribeKey"],
resources: ["*"],
conditions: {
StringEquals: { "aws:PrincipalOrgID": orgId },
StringLike: {
"kms:EncryptionContext:aws:cloudtrail:arn": `arn:${this.partition}:cloudtrail:*:*:trail/*`,
"aws:SourceArn": `arn:${this.partition}:cloudtrail:*:*:trail/*`,
},
},
})
);
trailKey.addToResourcePolicy(
new iam.PolicyStatement({
sid: "AllowCloudTrailDescribeKey",
effect: iam.Effect.ALLOW,
principals: [new iam.ServicePrincipal("cloudtrail.amazonaws.com")],
actions: ["kms:DescribeKey"],
resources: ["*"],
})
);
// Central, pre-existing access-logs bucket (manually created, imported
// read-only) — receives S3 server access logs for the trail bucket (CIS 3.6).
const accessLogsBucket = s3.Bucket.fromBucketName(
this,
"AccessLogsBucket",
"seahaven-s3-access-logs"
);
// ── Hardened, tamper-resistant log bucket ──
// Private (BPA all on), KMS-encrypted, versioned, TLS-only, Object-Lock
// GOVERNANCE 365d so logs cannot be silently deleted/overwritten.
const logBucket = new s3.Bucket(this, "TrailLogBucket", {
bucketName: `seahaven-cloudtrail-logs-${this.account}`,
encryption: s3.BucketEncryption.KMS,
encryptionKey: trailKey,
bucketKeyEnabled: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
versioned: true,
objectLockEnabled: true,
objectLockDefaultRetention: s3.ObjectLockRetention.governance(
cdk.Duration.days(365)
),
serverAccessLogsBucket: accessLogsBucket,
serverAccessLogsPrefix: "cloudtrail-bucket-access/",
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,
});
// ── Sensitive CloudWatch Logs CMK (M-24 / INFRA-96) ──
// Dedicated key for encrypting the CloudTrail CW log group and the
// finance/PII Lambda log groups (those live in other stacks and are
// associated via CLI until codified in their owning repos — see README).
const logsKey = new LogsKey(this, "LogsKey");
// ── Stable-named CloudTrail log group (INFRA-19) ──
// Previously the L2 Trail construct auto-created an anonymous log group
// (CDK-generated name with a hash suffix). The 15 CIS Section 4 metric
// filters in CisMonitoring imported it by that hardcoded generated name,
// which changes if the Trail/log group is ever recreated — causing all 15
// filters to silently detach with no error.
//
// This explicit LogGroup uses a stable, human-readable name so the filters
// can reference the CDK object (not a string constant). The group is passed
// to the Trail via cloudWatchLogGroup, and the same object is forwarded to
// CisMonitoring. RETAIN ensures historical audit logs are never destroyed
// when the stack is updated or deleted.
//
// DEPLOY NOTE: This is a one-time replacement of the auto-created log group
// with an explicit named one. CloudFormation will DELETE the old auto-named
// group and CREATE this new stable-named group. The old group (with its
// historical audit logs) is ORPHANED in AWS — it will NOT be deleted because
// CloudFormation loses track of it; the logs remain accessible in the
// CloudWatch console under the old name. No audit history is destroyed.
const trailLogGroup = new logs.LogGroup(this, "TrailLogGroup", {
logGroupName: "seahaven-account-baseline-trail-logs",
retention: logs.RetentionDays.ONE_YEAR,
encryptionKey: logsKey.key,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// ── Multi-region trail ──
// Management events (read + write), log-file validation, global service
// events, delivered to the KMS-encrypted bucket and to CloudWatch Logs.
// Data events (CIS 3.10/3.11) intentionally deferred — management events
// only for now to control cost (see README).
const trail = new cloudtrail.Trail(this, "Trail", {
trailName,
bucket: logBucket,
encryptionKey: trailKey,
isMultiRegionTrail: true,
// INFRA-73: promote to an organization trail. CloudTrail org
// trusted-access is already enabled on the management account; this makes
// the trail collect every member account's events into this bucket.
// Passing orgId lets the L2 construct auto-attach the AWSLogs/<orgId>/*
// bucket-policy PutObject statement (scoped to this trail's SourceArn).
isOrganizationTrail: true,
orgId,
includeGlobalServiceEvents: true,
enableFileValidation: true,
sendToCloudWatchLogs: true,
cloudWatchLogGroup: trailLogGroup,
managementEvents: cloudtrail.ReadWriteType.ALL,
});
// ── Day 1 detective layer + governance toggles ──
// Config (H-2), GuardDuty (H-3), Security Hub (H-4), Access Analyzer (M-5).
new DetectiveControls(this, "DetectiveControls");
// Monthly cost budget (M-10). Other governance toggles are CLI + documented.
new GovernanceToggles(this, "GovernanceToggles", {
monthlyLimitUsd: props.monthlyBudgetUsd,
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,
trailLogGroup,
});
new FlowLogs(this, "FlowLogs");
new SesMonitoring(this, "SesMonitoring");
// Shared CloudFront WAF WebACL (M-17); ARN published to SSM for app stacks.
new AppWebAcl(this, "AppWebAcl");
// ── Day 5 AI governance ──
// Bedrock model invocation logging destinations + delivery role (H-20).
// The account-level logging configuration itself has no CFN resource type;
// applied via CLI post-deploy (see lib/bedrock-logging.ts header).
new BedrockLogging(this, "BedrockLogging");
cdk.Tags.of(this).add("Project", "account-baseline");
cdk.Tags.of(this).add("Owner", "adam@seahavenind.com");
cdk.Tags.of(this).add("Environment", "prod");
cdk.Tags.of(this).add("ManagedBy", "cdk");
new cdk.CfnOutput(this, "TrailArn", { value: trail.trailArn });
new cdk.CfnOutput(this, "LogBucketName", { value: logBucket.bucketName });
new cdk.CfnOutput(this, "TrailKmsKeyArn", { value: trailKey.keyArn });
}
}