seahaven-account-baseline/lib/account-baseline-stack.ts
Adam Moussa 3ab3bc773a
Merge external-dev member baseline; rename to seahaven-org-baseline (#43)
* Parameterize baseline constructs for multi-account reuse

DetectiveControls, FlowLogs, and GovernanceToggles were forked into
seahaven-external-dev-baseline with only physical-name and VPC-sourcing
differences. Prefix/name props let one implementation serve both
accounts; synthesized templates are unchanged (verified: empty cdk diff
against all deployed stacks).

* Absorb external-dev member baseline stack

Moves seahaven-external-dev-baseline's stack in as MemberBaselineStack,
construct ids and physical names byte-identical to the deployed stack
(logical IDs are path-derived; empty cdk diff verified via change set
against 396287094661). Retires the forked repo so member-account
baselines share one drift surface and one dependency pin.

* Rename package to seahaven-org-baseline

Prepares the repo rename: the app now spans the management account and
org member accounts, so 'account-baseline' undersells the scope. README
documents the two-account deploy topology and logical-ID constraints.

* Commit extdev flow-log VPC ids in code, not -c context

Security review SH-ORG-004 (confirmed high): with the ids sourced from
ephemeral cdk context, any context-less deploy silently removes every
flow log in the isolated account. A committed list makes the attachment
set reviewable and immune to a forgotten -c flag. Empty list matches
the deployed stack (zero diff).

* Split CD into per-account deploy jobs

The app now spans two AWS accounts; cdk deploy --all under one role
fails on the other account's stacks (security review IAC-01). Each job
passes explicit stack selectors and its own account's OIDC role via the
new cd-cdk stacks input.
2026-07-14 13:53:07 -04:00

273 lines
12 KiB
TypeScript
Raw Permalink 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 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;
/**
* VPC ids to attach ALL-traffic flow logs to (H-14). Logical IDs are
* index-derived — only append, never reorder (see lib/flow-logs.ts).
*/
readonly flowLogVpcIds: 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,
// CloudTrail Insights (§37): compensating control for the residual risk
// accepted in #36 (CFN/Config-proxied denials excluded from CIS 4.1) and
// the low-and-slow evasion surface in the UnauthorizedApiCalls alarm.
// ApiCallRateInsight flags anomalous write-API spikes; ApiErrorRateInsight
// flags anomalous errored/denied call rates — including the denials CIS
// 4.1 intentionally filters out. Per-event cost (≈$0.35/100k management
// events); an org trail with 10–15M management events/month adds roughly
// $35–$53/month. CIS 4.1 alarm + GuardDuty + Security Hub (CIS v3.0) are
// already live, so this is defence-in-depth, not an urgent gap-fill.
insightTypes: [
cloudtrail.InsightType.API_CALL_RATE,
cloudtrail.InsightType.API_ERROR_RATE,
],
});
// ── Day 1 detective layer + governance toggles ──
// Config (H-2), GuardDuty (H-3), Security Hub (H-4), Access Analyzer (M-5).
new DetectiveControls(this, "DetectiveControls", {
namePrefix: "seahaven",
});
// Monthly cost budget (M-10). Other governance toggles are CLI + documented.
new GovernanceToggles(this, "GovernanceToggles", {
budgetName: "seahaven-monthly-cost",
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", {
namePrefix: "seahaven",
vpcIds: props.flowLogVpcIds,
});
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 });
}
}