From e9a184cf96aa1cb9b532fa20815abe5b9fa4cdb4 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:03:18 -0400 Subject: [PATCH 1/6] [INFRA-91/89/16/88/73] Reconcile out-of-band baseline changes + add missing detective controls (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Codify primary vault lock + add backups (INFRA-89, INFRA-88) INFRA-89: codify the GOVERNANCE Vault Lock applied out-of-band on the seahaven-primary vault (MinRetention 1d, MaxRetention 2555d, no changeableFor = admin-removable) so it lives in IaC. Values match the live lock exactly, so the deploy is a no-op adoption. Add a scoped vault access policy that denies manual recovery-point deletion and lock/policy tampering to all principals except the AWS Backup service role and the break-glass SSO AdministratorAccess role, so automatic lifecycle expiry still works but humans cannot prune recovery points by hand. Cross-review (GPT-4.1) BLOCK: NotPrincipal does not support wildcard ARN matching, so the SSO exemption is expressed as Effect DENY with Principal * and a StringNotLike condition on aws:PrincipalArn, which does support wildcards. This avoids an unrecoverable vault lockout. INFRA-88: add 6 S3 buckets (kb-docs, payroll-emails [PII], amazon-po, extracted-amazon-po, proposal-system uploads + generated) to the phase2-offsite-everything selection. Versioning verified enabled on all 6 against the live account (S3 backup requires versioning). Refs: INFRA-89, INFRA-88 * Promote account trail to organization trail (INFRA-73) INFRA-73: set isOrganizationTrail on seahaven-org-trail and pass orgId (o-9kufuzz6b4) so the L2 Trail attaches the AWSLogs//* bucket PutObject statement for member-account delivery. CloudTrail org trusted-access is already enabled on the management account. Broaden the KMS key policy with an org-scoped GenerateDataKey/DescribeKey statement for member-account trail delivery, guarded by aws:PrincipalOrgID. The existing single-account statements are preserved so management-account delivery is unaffected. Cross-review (GPT-4.1) BLOCK: the member KMS SourceArn and encryption context must be wildcarded across accounts (org-trail shadow trails present the member account id), not pinned to the management account, or member delivery silently fails. Fixed before checkpoint. CHECKPOINT: delicate org-trail KMS/bucket-policy change — code + diff captured for review, NOT deployed. Refs: INFRA-73 * Add secondary-region baseline stacks (INFRA-91, INFRA-16) INFRA-91: codify the Bedrock model-invocation logging applied out-of-band in us-west-2 and us-east-2 (per-region delivery role seahaven-bedrock-invocation-logging- + log group /aws/bedrock/model-invocations 90d, CloudWatch-only). The account-level logging config itself has no CFN resource type and is applied via CLI (already live), same as us-east-1. INFRA-16: add the still-missing us-east-2 detective controls — AWS Config recorder role + delivery bucket (recorder/channel via CLI to avoid the CFN stabilization deadlock seen in us-east-1) and Security Hub with FSBP + CIS v3.0. GuardDuty + flow logs already live in us-east-2 and are left for a follow-up adoption to keep this change non-destructive. The us-east-1 baseline stays region-pinned; these are separate RegionalBaselineStack instances composed opt-in per region. CHECKPOINT: new multi-region stacks. The live Bedrock role + log group already exist (CLI-created), so a plain deploy would collide — these need cdk import / changeset adoption, not cdk deploy. Code + diff captured for review, NOT deployed. Refs: INFRA-91, INFRA-16 * Drop vault access policy from this deploy; tracked in INFRA-94 (kept governance lock codify + selection) --- bin/app.ts | 29 ++++- lib/account-baseline-stack.ts | 38 +++++++ lib/backup-stack.ts | 33 +++++- lib/bedrock-logging-regional.ts | 71 ++++++++++++ lib/regional-baseline-stack.ts | 189 ++++++++++++++++++++++++++++++++ 5 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 lib/bedrock-logging-regional.ts create mode 100644 lib/regional-baseline-stack.ts diff --git a/bin/app.ts b/bin/app.ts index 72de6ac..1a08184 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -4,16 +4,43 @@ import * as cdk from "aws-cdk-lib"; import { AccountBaselineStack } from "../lib/account-baseline-stack"; import { BackupOffsiteStack } from "../lib/backup-offsite-stack"; import { BackupStack } from "../lib/backup-stack"; +import { RegionalBaselineStack } from "../lib/regional-baseline-stack"; + +const ACCOUNT = "328440206208"; const app = new cdk.App(); new AccountBaselineStack(app, "account-baseline", { stackName: "seahaven-account-baseline", - env: { account: "328440206208", region: "us-east-1" }, + env: { account: ACCOUNT, region: "us-east-1" }, monthlyBudgetUsd: 1200, budgetAlertEmail: "adam@seahavenind.com", }); +// ── Secondary-region baselines (INFRA-16, INFRA-91) ────────────────────────── +// The us-east-1 baseline above is region-pinned by design. These stacks extend +// a minimal detective/logging footprint into the secondary regions, codifying +// state applied out-of-band this week so it lives in IaC. + +// us-west-2: Bedrock invocation logging only (INFRA-91). Shares the region with +// the offsite backup vault but is an independent concern (separate stack). +new RegionalBaselineStack(app, "regional-baseline-us-west-2", { + stackName: "seahaven-regional-baseline-us-west-2", + env: { account: ACCOUNT, region: "us-west-2" }, + bedrockLogging: true, +}); + +// us-east-2: Bedrock invocation logging (INFRA-91) + the still-missing AWS +// Config recorder and Security Hub (INFRA-16). GuardDuty + flow logs already +// live here (adopted as a follow-up, see lib/regional-baseline-stack.ts). +new RegionalBaselineStack(app, "regional-baseline-us-east-2", { + stackName: "seahaven-regional-baseline-us-east-2", + env: { account: ACCOUNT, region: "us-east-2" }, + bedrockLogging: true, + configRecorder: true, + securityHub: true, +}); + // AWS Backup (audit C-7). Offsite vault (us-west-2) must exist before the // primary plan that copies to it, hence the explicit dependency. const backupOffsite = new BackupOffsiteStack(app, "backup-offsite", { diff --git a/lib/account-baseline-stack.ts b/lib/account-baseline-stack.ts index 3cadd24..128d423 100644 --- a/lib/account-baseline-stack.ts +++ b/lib/account-baseline-stack.ts @@ -37,6 +37,10 @@ export class AccountBaselineStack extends cdk.Stack { 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( @@ -71,6 +75,33 @@ export class AccountBaselineStack extends cdk.Stack { }, }) ); + // 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", @@ -132,6 +163,13 @@ export class AccountBaselineStack extends cdk.Stack { 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//* + // bucket-policy PutObject statement (scoped to this trail's SourceArn). + isOrganizationTrail: true, + orgId, includeGlobalServiceEvents: true, enableFileValidation: true, sendToCloudWatchLogs: true, diff --git a/lib/backup-stack.ts b/lib/backup-stack.ts index 76f126f..f53d089 100644 --- a/lib/backup-stack.ts +++ b/lib/backup-stack.ts @@ -80,12 +80,29 @@ export class BackupStack extends cdk.Stack { }) ); - // Primary vault is intentionally NOT locked — it is the working copy; the - // offsite vault carries the immutability guarantee. + // Primary vault — GOVERNANCE Vault Lock (INFRA-89). Codifies the lock applied + // out-of-band 2026-06: MinRetention 1d, MaxRetention 2555d (~7y), no + // `changeableFor` (LockDate null = admin-removable, GOVERNANCE not + // COMPLIANCE) so it stays adjustable while the plan is validated. This block + // is written to MATCH the live lock exactly, so the deploy diff is a no-op + // adoption — it does not change the live vault. + // + // NOTE: the scoped vault access policy that previously lived here was DROPPED + // from this deploy (INFRA-94). Attaching a BackupVaultAccessPolicy that + // denies DeleteBackupVault / PutBackupVaultLockConfiguration to all but a + // break-glass principal made the vault unmanageable by CloudFormation/CDK. + // The deny-manual-deletion control is tracked separately in INFRA-94 and + // will be reintroduced via a safe mechanism. Governance lock codify + + // backup selections land here. const primaryVault = new backup.BackupVault(this, "PrimaryVault", { backupVaultName: "seahaven-primary", encryptionKey: vaultKey, removalPolicy: cdk.RemovalPolicy.RETAIN, + lockConfiguration: { + minRetention: cdk.Duration.days(1), + maxRetention: cdk.Duration.days(2555), + // No `changeableFor` → GOVERNANCE mode, admin-removable (matches live). + }, }); // Cross-region copy destination, referenced by literal ARN (the offsite @@ -234,6 +251,18 @@ export class BackupStack extends cdk.Stack { `arn:aws:ec2:us-east-1:${this.account}:volume/${v}` ) ), + // S3 — additional critical/PII buckets (INFRA-88). Explicit-ARN, same as + // the phase-1 set. Versioning verified enabled on all 6 against the live + // account 2026-06-08 (S3 backup requires versioning). payroll-emails is + // PII → immutable offsite copy is the point of including it. + ...[ + "seahaven-kb-docs-328440206208", + "seahaven-payroll-emails-328440206208", + "amazon-po", + "extracted-amazon-po", + "proposal-system-uploads-328440206208", + "proposal-system-generated-328440206208", + ].map((b) => backup.BackupResource.fromArn(`arn:aws:s3:::${b}`)), ], }); diff --git a/lib/bedrock-logging-regional.ts b/lib/bedrock-logging-regional.ts new file mode 100644 index 0000000..ba0d3e6 --- /dev/null +++ b/lib/bedrock-logging-regional.ts @@ -0,0 +1,71 @@ +import * as cdk from "aws-cdk-lib"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as logs from "aws-cdk-lib/aws-logs"; +import { Construct } from "constructs"; + +/** + * Regional Bedrock model-invocation logging destination + delivery role + * (INFRA-91). Bedrock invocation logging is account-level *per region*, so + * extending the us-east-1 coverage (lib/bedrock-logging.ts) to the other + * regions where Bedrock is reachable (us-west-2, us-east-2) requires a separate + * regional stack with its own log group + delivery role per region. + * + * This codifies the out-of-band CLI state applied 2026-06 to MATCH exactly so + * the adoption diff is minimal: + * - IAM role seahaven-bedrock-invocation-logging- + * - log group /aws/bedrock/model-invocations (90d) + * - CloudWatch-only delivery (no S3 leg — unlike us-east-1, these regions log + * to CloudWatch only; the large-payload S3 bucket is us-east-1 only). + * + * Like us-east-1, the account-level logging configuration itself has no CFN + * resource type (`PutModelInvocationLoggingConfiguration`); it is applied via + * CLI per region (already live — see README). This construct owns only the + * destinations + role the live config references. + */ +export class BedrockLoggingRegional extends Construct { + public readonly logGroup: logs.LogGroup; + public readonly deliveryRole: iam.Role; + + constructor(scope: Construct, id: string) { + super(scope, id); + + const stack = cdk.Stack.of(this); + + this.logGroup = new logs.LogGroup(this, "InvocationLogGroup", { + logGroupName: "/aws/bedrock/model-invocations", + retention: logs.RetentionDays.THREE_MONTHS, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + + // Region-suffixed role name matches the live CLI-created role so CFN can + // adopt it by import rather than creating a colliding new one. + this.deliveryRole = new iam.Role(this, "DeliveryRole", { + roleName: `seahaven-bedrock-invocation-logging-${stack.region}`, + assumedBy: new iam.ServicePrincipal("bedrock.amazonaws.com", { + conditions: { + StringEquals: { "aws:SourceAccount": stack.account }, + ArnLike: { + "aws:SourceArn": `arn:aws:bedrock:${stack.region}:${stack.account}:*`, + }, + }, + }), + }); + + this.deliveryRole.addToPolicy( + new iam.PolicyStatement({ + actions: ["logs:CreateLogStream", "logs:PutLogEvents"], + resources: [ + this.logGroup.logGroupArn, + `${this.logGroup.logGroupArn}:log-stream:*`, + ], + }), + ); + + new cdk.CfnOutput(this, "BedrockLogGroupName", { + value: this.logGroup.logGroupName, + }); + new cdk.CfnOutput(this, "BedrockLoggingRoleArn", { + value: this.deliveryRole.roleArn, + }); + } +} diff --git a/lib/regional-baseline-stack.ts b/lib/regional-baseline-stack.ts new file mode 100644 index 0000000..2ddf055 --- /dev/null +++ b/lib/regional-baseline-stack.ts @@ -0,0 +1,189 @@ +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 securityhub from "aws-cdk-lib/aws-securityhub"; +import { Construct } from "constructs"; +import { BedrockLoggingRegional } from "./bedrock-logging-regional"; + +/** + * Regional security-baseline stack for secondary regions (INFRA-16, INFRA-91). + * + * The account baseline (lib/account-baseline-stack.ts) is us-east-1 only by + * design. This stack extends a minimal detective/logging footprint into the + * other regions where workloads or Bedrock traffic land, WITHOUT duplicating + * the full us-east-1 stack. + * + * Composition is opt-in per region via props so one class serves both + * secondary regions: + * - bedrockLogging → INFRA-91 Bedrock invocation-logging destination + role + * (us-west-2 + us-east-2; codifies live CLI state) + * - configRecorder → INFRA-16 AWS Config recorder role + delivery bucket + * (us-east-2; recorder/channel applied via CLI, see header) + * - securityHub → INFRA-16 Security Hub + FSBP/CIS standards (us-east-2) + * + * GuardDuty and default-VPC flow logs already exist in us-east-2 (applied + * out-of-band) and are intentionally NOT adopted here yet — importing live + * resources of those L1 types risks a replace diff; they are tracked as a + * follow-up so this reconciliation stays additive/non-destructive. + */ +export interface RegionalBaselineStackProps extends cdk.StackProps { + /** INFRA-91: stand up Bedrock invocation-logging destination + delivery role. */ + readonly bedrockLogging?: boolean; + /** INFRA-16: stand up AWS Config recorder role + delivery bucket. */ + readonly configRecorder?: boolean; + /** INFRA-16: enable Security Hub with FSBP + CIS v3.0 standards. */ + readonly securityHub?: boolean; +} + +export class RegionalBaselineStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: RegionalBaselineStackProps) { + super(scope, id, props); + + if (props.bedrockLogging) { + // INFRA-91 — codifies live us-west-2 / us-east-2 Bedrock logging. + new BedrockLoggingRegional(this, "BedrockLogging"); + } + + if (props.configRecorder) { + // INFRA-16 — AWS Config in us-east-2. Same pattern as the us-east-1 + // DetectiveControls construct: the role + delivery bucket live in IaC; + // the recorder + delivery channel are applied via CLI post-deploy because + // the L1 ConfigurationRecorder/DeliveryChannel pair deadlocks CFN (the + // recorder will not reach CREATE_COMPLETE without a channel, and the + // channel cannot be created until the recorder completes — observed in + // us-east-1 2026-06-01). Commands are documented in the README. + const configBucket = new s3.Bucket(this, "ConfigBucket", { + bucketName: `seahaven-config-${this.region}-${this.account}`, + encryption: s3.BucketEncryption.S3_MANAGED, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + enforceSSL: true, + versioned: true, + lifecycleRules: [ + { + id: "expire-old-config", + expiration: cdk.Duration.days(365), + abortIncompleteMultipartUploadAfter: cdk.Duration.days(7), + }, + ], + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + + configBucket.addToResourcePolicy( + new iam.PolicyStatement({ + sid: "AWSConfigBucketPermissionsCheck", + effect: iam.Effect.ALLOW, + principals: [new iam.ServicePrincipal("config.amazonaws.com")], + actions: ["s3:GetBucketAcl", "s3:ListBucket"], + resources: [configBucket.bucketArn], + conditions: { + StringEquals: { "aws:SourceAccount": this.account }, + }, + }) + ); + configBucket.addToResourcePolicy( + new iam.PolicyStatement({ + sid: "AWSConfigBucketDelivery", + effect: iam.Effect.ALLOW, + principals: [new iam.ServicePrincipal("config.amazonaws.com")], + actions: ["s3:PutObject"], + resources: [ + configBucket.arnForObjects(`AWSLogs/${this.account}/Config/*`), + ], + conditions: { + StringEquals: { + "s3:x-amz-acl": "bucket-owner-full-control", + "aws:SourceAccount": this.account, + }, + }, + }) + ); + + // Region-suffixed role name so it does not collide with the us-east-1 + // seahaven-config-recorder-role (IAM roles are global by name). + const recorderRole = new iam.Role(this, "ConfigRecorderRole", { + roleName: `seahaven-config-recorder-role-${this.region}`, + assumedBy: new iam.ServicePrincipal("config.amazonaws.com"), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWS_ConfigRole" + ), + ], + }); + recorderRole.addToPolicy( + new iam.PolicyStatement({ + sid: "ConfigDeliveryToBucket", + effect: iam.Effect.ALLOW, + actions: ["s3:PutObject"], + resources: [ + configBucket.arnForObjects(`AWSLogs/${this.account}/Config/*`), + ], + conditions: { + StringEquals: { "s3:x-amz-acl": "bucket-owner-full-control" }, + }, + }) + ); + recorderRole.addToPolicy( + new iam.PolicyStatement({ + sid: "ConfigBucketAcl", + effect: iam.Effect.ALLOW, + actions: ["s3:GetBucketAcl"], + resources: [configBucket.bucketArn], + }) + ); + + new cdk.CfnOutput(this, "ConfigRecorderRoleArn", { + value: recorderRole.roleArn, + }); + new cdk.CfnOutput(this, "ConfigBucketName", { + value: configBucket.bucketName, + }); + } + + if (props.securityHub) { + // INFRA-16 — Security Hub (FSBP + CIS v3.0) in us-east-2. Mirrors the + // us-east-1 DetectiveControls Security Hub block. Findings populate once + // the Config recorder above is recording. + const hub = new securityhub.CfnHub(this, "SecurityHub", { + enableDefaultStandards: false, + controlFindingGenerator: "SECURITY_CONTROL", + autoEnableControls: true, + }); + + const fsbpArn = cdk.Arn.format( + { + service: "securityhub", + region: this.region, + account: "", + resource: "standards", + resourceName: "aws-foundational-security-best-practices/v/1.0.0", + }, + this + ); + const cisArn = cdk.Arn.format( + { + service: "securityhub", + region: this.region, + account: "", + resource: "standards", + resourceName: "cis-aws-foundations-benchmark/v/3.0.0", + }, + this + ); + + const fsbp = new securityhub.CfnStandard(this, "StandardFSBP", { + standardsArn: fsbpArn, + }); + fsbp.node.addDependency(hub); + + const cis = new securityhub.CfnStandard(this, "StandardCIS", { + standardsArn: cisArn, + }); + cis.node.addDependency(hub); + } + + 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"); + } +} From 5002ed86d813c317026575ea842606534e76ae73 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:34:01 -0400 Subject: [PATCH 2/6] [INFRA-94] Add Backup vault access policy on seahaven-primary (#19) Reintroduce the scoped vault access policy that was split out of INFRA-89 after two lockout-class bugs. Adds a Deny on the destructive recovery-point and vault-lifecycle actions (DeleteRecoveryPoint, UpdateRecoveryPointLifecycle, DeleteBackupVault, DeleteBackupVaultAccessPolicy, DeleteBackupVaultLockConfiguration, PutBackupVaultLockConfiguration) for every principal except three exempted operational identities via StringNotLike on aws:PrincipalArn: 1. SSO AdministratorAccess role (break-glass human admin) 2. seahaven-backup-service-role (AWS Backup lifecycle) 3. cdk-hnb659fds-cfn-exec-role-* (CloudFormation manages the vault) The CFN-exec-role exemption is the fix for the 2026-06-08 strand failure: without it CloudFormation cannot re-assert the vault lock config and the deploy strands the policy. Uses Deny + AnyPrincipal + StringNotLike (not NotPrincipal, which rejects wildcard ARNs). aws:PrincipalArn normalizes assumed-role sessions to the IAM role ARN, so the iam::role/ ARN forms are correct (AWS docs: "Do not specify the assumed role session ARN as a value for this condition key"). Deployed and verified: deploy succeeded (proves exec role not locked out), access policy present with all three exemptions, vault still Locked (min1/max2555, LockDate null, 168 RPs), follow-up cdk diff clean (no drift). --- lib/backup-stack.ts | 70 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 7 deletions(-) diff --git a/lib/backup-stack.ts b/lib/backup-stack.ts index f53d089..a18f28b 100644 --- a/lib/backup-stack.ts +++ b/lib/backup-stack.ts @@ -87,13 +87,16 @@ export class BackupStack extends cdk.Stack { // is written to MATCH the live lock exactly, so the deploy diff is a no-op // adoption — it does not change the live vault. // - // NOTE: the scoped vault access policy that previously lived here was DROPPED - // from this deploy (INFRA-94). Attaching a BackupVaultAccessPolicy that - // denies DeleteBackupVault / PutBackupVaultLockConfiguration to all but a - // break-glass principal made the vault unmanageable by CloudFormation/CDK. - // The deny-manual-deletion control is tracked separately in INFRA-94 and - // will be reintroduced via a safe mechanism. Governance lock codify + - // backup selections land here. + // NOTE: the scoped vault access policy is (re)introduced below (INFRA-94) + // with the fix for the two lockout-class bugs that got it split out of + // INFRA-89: the deny statement now exempts THREE principals via + // StringNotLike on aws:PrincipalArn — the SSO AdministratorAccess role (break + // glass), the backup service role, AND the CDK CFN execution role. The + // CFN-exec-role exemption is MANDATORY: without it CloudFormation cannot + // re-assert the vault lock config / manage the vault and the deploy strands + // the policy (this happened 2026-06-08). NotPrincipal is deliberately NOT + // used (it rejects wildcard ARNs). Governance lock codify + backup + // selections land here. const primaryVault = new backup.BackupVault(this, "PrimaryVault", { backupVaultName: "seahaven-primary", encryptionKey: vaultKey, @@ -105,6 +108,59 @@ export class BackupStack extends cdk.Stack { }, }); + // Vault access policy (INFRA-94): Deny the destructive recovery-point and + // vault-lifecycle actions to EVERY principal EXCEPT the three operational + // identities below. This is defense-in-depth on top of the GOVERNANCE lock — + // it blocks manual deletion / lifecycle tampering even from accounts that + // hold the equivalent IAM permissions. + // + // Deny (not Allow): a resource-policy Deny overrides any identity-based + // Allow, which is exactly what we want for a guardrail. The StringNotLike + // condition means "this Deny applies UNLESS the caller's ARN matches one of + // the exempted patterns" — i.e. the three exempt principals are NOT denied. + // + // Exemptions (all THREE required): + // 1. SSO AdministratorAccess role — break-glass human admin path. Matched by + // wildcard because the AWSReservedSSO role name carries a permission-set + // hash suffix. + // 2. seahaven-backup-service-role — AWS Backup uses it for lifecycle + // expiry of recovery points; denying it would break the plan's + // deleteAfter cleanup. + // 3. cdk-hnb659fds-cfn-exec-role — the CloudFormation execution role. CFN + // re-asserts the vault lock config and manages the vault on every deploy; + // omitting it strands the policy and fails the deploy (INFRA-94, + // 2026-06-08). Wildcard-suffixed to cover the region-qualified name. + // + // NotPrincipal is intentionally avoided — it does not accept wildcard ARNs. + primaryVault.addToAccessPolicy( + new iam.PolicyStatement({ + sid: "DenyDestructiveActionsExceptOperationalRoles", + effect: iam.Effect.DENY, + principals: [new iam.AnyPrincipal()], + actions: [ + "backup:DeleteRecoveryPoint", + "backup:UpdateRecoveryPointLifecycle", + "backup:DeleteBackupVault", + "backup:DeleteBackupVaultAccessPolicy", + "backup:DeleteBackupVaultLockConfiguration", + "backup:PutBackupVaultLockConfiguration", + ], + resources: ["*"], + conditions: { + StringNotLike: { + "aws:PrincipalArn": [ + // 1. SSO AdministratorAccess (break-glass human admin) + `arn:aws:iam::${this.account}:role/aws-reserved/sso.amazonaws.com/*AWSReservedSSO_AdministratorAccess*`, + // 2. AWS Backup service role (lifecycle expiry of recovery points) + `arn:aws:iam::${this.account}:role/seahaven-backup-service-role`, + // 3. CDK CloudFormation execution role (MANDATORY — manages vault) + `arn:aws:iam::${this.account}:role/cdk-hnb659fds-cfn-exec-role-*`, + ], + }, + }, + }) + ); + // Cross-region copy destination, referenced by literal ARN (the offsite // stack is in another region; a literal ARN avoids crossRegionReferences / // SSM exports). Stack ordering is enforced via addDependency in bin/app.ts. From 77d9d6c574f750bc5593c371da4491a330db96aa Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:04:36 -0400 Subject: [PATCH 3/6] [INFRA-96] CMK-encrypt sensitive CloudWatch log groups (M-24) (#20) * [INFRA-96] CMK-encrypt sensitive CloudWatch log groups (M-24) Add a dedicated customer-managed CMK (alias/seahaven-logs) for encrypting the sensitive CloudWatch Logs groups (CloudTrail + finance/PII Lambdas). - lib/logs-key.ts: LogsKey construct. Key policy grants the CloudWatch Logs service principal (logs.us-east-1.amazonaws.com) Encrypt*/Decrypt*/ ReEncrypt*/GenerateDataKey*/DescribeKey, scoped by the kms:EncryptionContext:aws:logs:arn condition (REQUIRED per AWS docs or log delivery breaks). Cross-reviewed (GPT-4.1): tightened Describe* -> DescribeKey; CreateGrant omitted (not needed for plain log-group encryption). - account-baseline-stack.ts: instantiate LogsKey and set KmsKeyId on the L2 Trail's CloudWatch log group in place (escape hatch on the existing AWS::Logs::LogGroup) so it keeps the same logical id + physical name - additive, no replacement, CIS Section-4 metric filters (which import the group by name) keep working, live audit trail not disrupted. Gated by context `encryptTrailLogGroup` so the CMK can be smoke-tested on a low-risk Lambda group before the most-sensitive CloudTrail group. Finance/PII Lambda log groups (exec-aide-*, payments-*, po-email-processor, vendor-reply-processor) are owned by other stacks and associated to this CMK via the CLI for now; codifying KmsKeyId in those repos is tracked as drift. * [INFRA-96] Document sensitive-logs CMK (M-24) in README --- README.md | 18 +++++++++ lib/account-baseline-stack.ts | 25 +++++++++++++ lib/logs-key.ts | 69 +++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 lib/logs-key.ts diff --git a/README.md b/README.md index 9f2a0f8..f94b89a 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,24 @@ this only affects the legacy `AWS-MonthlyBilling` CloudWatch alarm (L-8). | 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 | +| Sensitive-logs CMK | `LogsKey/Key` (`alias/seahaven-logs`) | M-24 | Encrypts sensitive CloudWatch Logs groups. Key policy grants `logs.us-east-1.amazonaws.com` Encrypt*/Decrypt*/ReEncrypt*/GenerateDataKey*/DescribeKey scoped by `kms:EncryptionContext:aws:logs:arn` (required or log delivery breaks). Rotation on, RETAIN. Applied in place to `TrailLogGroup` via escape hatch (same logical id/name). Cross-reviewed | + +**M-24 sensitive log groups:** `alias/seahaven-logs` encrypts the CloudTrail CW +log group (codified here) plus the finance/PII Lambda groups owned by other +stacks — `exec-aide-*`, `payments-*`, `po-email-processor`, `vendor-reply-processor` +— which are associated via `aws logs associate-kms-key` and tracked as drift to +codify in their owning repos. The CloudTrail group is encrypted in place (escape +hatch on the existing `AWS::Logs::LogGroup`) so it is additive: same logical id + +physical name, no replacement, CIS Section-4 metric filters keep working. A +context flag `encryptTrailLogGroup` (default `true`) allows rolling the CMK out +and smoke-testing it on a low-risk Lambda group before the CloudTrail group: + +```bash +# Phase 1: deploy CMK only, validate on a low-risk group +cdk deploy account-baseline --context encryptTrailLogGroup=false +# Phase 2: encrypt the CloudTrail group (default) +cdk deploy account-baseline +``` **H-1 log group:** the metric filters attach to the existing CloudTrail CloudWatch Logs group by name (`seahaven-account-baseline-TrailLogGroup4CBE3AF5-…`), diff --git a/lib/account-baseline-stack.ts b/lib/account-baseline-stack.ts index 128d423..7579625 100644 --- a/lib/account-baseline-stack.ts +++ b/lib/account-baseline-stack.ts @@ -5,6 +5,7 @@ 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"; @@ -177,6 +178,30 @@ export class AccountBaselineStack extends cdk.Stack { managementEvents: cloudtrail.ReadWriteType.ALL, }); + // ── 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"); + + // CMK-encrypt the Trail's CloudWatch Logs group in place. The L2 Trail + // construct owns this group (path Trail/LogGroup) and does not expose an + // encryptionKey prop for it, so we set KmsKeyId via escape hatch. This keeps + // the same logical ID and physical name, so it is additive (no replacement) + // and the CIS Section 4 metric filters that import the group by name (H-1) + // keep working, and the live audit trail is never disrupted. + // + // Gated by context `encryptTrailLogGroup` (default true) so the CMK can be + // rolled out and smoke-tested on a low-risk Lambda log group first, before + // applying it to the most-sensitive CloudTrail group (INFRA-96 step 3). + const encryptTrailLogGroup = + this.node.tryGetContext("encryptTrailLogGroup") !== "false"; + if (encryptTrailLogGroup && trail.logGroup) { + const cfnTrailLogGroup = trail.logGroup.node + .defaultChild as logs.CfnLogGroup; + cfnTrailLogGroup.kmsKeyId = logsKey.key.keyArn; + } + // ── Day 1 detective layer + governance toggles ── // Config (H-2), GuardDuty (H-3), Security Hub (H-4), Access Analyzer (M-5). new DetectiveControls(this, "DetectiveControls"); diff --git a/lib/logs-key.ts b/lib/logs-key.ts new file mode 100644 index 0000000..9c863da --- /dev/null +++ b/lib/logs-key.ts @@ -0,0 +1,69 @@ +import * as cdk from "aws-cdk-lib"; +import * as kms from "aws-cdk-lib/aws-kms"; +import * as iam from "aws-cdk-lib/aws-iam"; +import { Construct } from "constructs"; + +/** + * Dedicated customer-managed CMK for encrypting sensitive CloudWatch Logs + * groups (audit M-24 / INFRA-96). + * + * Used by the account CloudTrail log group and the finance/PII Lambda log + * groups (exec-aide, payments, po/vendor email processors, qbo). This is a + * SEPARATE key from `alias/seahaven-alarm-topics` (SNS alarm topics) and + * `alias/cloudtrail-logs` (the CloudTrail S3 log-file CMK) — those have + * different service principals and encryption contexts. + * + * The key policy MUST grant the CloudWatch Logs service principal use of the + * key, scoped by the `kms:EncryptionContext:aws:logs:arn` condition, or log + * delivery to any CMK-encrypted group silently stops. (AWS docs: "Encrypt log + * data in CloudWatch Logs using AWS KMS".) Cross-reviewed 2026-06-08 + * (INFRA-96): dropped `Describe*` to the specific `DescribeKey`; CreateGrant is + * not required for plain log-group encryption so it is omitted. + */ +export class LogsKey extends Construct { + public readonly key: kms.Key; + + constructor(scope: Construct, id: string) { + super(scope, id); + + const stack = cdk.Stack.of(this); + + this.key = new kms.Key(this, "Key", { + alias: "seahaven-logs", + description: + "Encrypts sensitive CloudWatch Logs groups (CloudTrail + finance/PII Lambdas) — M-24/INFRA-96", + enableKeyRotation: true, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + + // CloudWatch Logs service principal must be able to use the key to deliver + // (encrypt) and read (decrypt) log events. The encryption-context condition + // binds the grant to this account's log groups only, so the key cannot be + // used to decrypt arbitrary data under the logs service principal. + this.key.addToResourcePolicy( + new iam.PolicyStatement({ + sid: "AllowCloudWatchLogsUseOfKey", + effect: iam.Effect.ALLOW, + principals: [ + new iam.ServicePrincipal(`logs.${stack.region}.amazonaws.com`), + ], + actions: [ + "kms:Encrypt*", + "kms:Decrypt*", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey", + ], + resources: ["*"], + conditions: { + ArnLike: { + "kms:EncryptionContext:aws:logs:arn": `arn:${stack.partition}:logs:${stack.region}:${stack.account}:log-group:*`, + }, + }, + }) + ); + + new cdk.CfnOutput(this, "LogsKeyArn", { value: this.key.keyArn }); + new cdk.CfnOutput(this, "LogsKeyAlias", { value: "alias/seahaven-logs" }); + } +} From ec620e4e7964ed661cf21aebf3617293b148f28c Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:04:42 -0400 Subject: [PATCH 4/6] [INFRA-95] Shared DynamoDB CMK for sensitive finance/PII tables (M-3) (#21) --- bin/app.ts | 11 ++++ lib/dynamodb-cmk-stack.ts | 105 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 lib/dynamodb-cmk-stack.ts diff --git a/bin/app.ts b/bin/app.ts index 1a08184..06564a7 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -5,6 +5,7 @@ import { AccountBaselineStack } from "../lib/account-baseline-stack"; import { BackupOffsiteStack } from "../lib/backup-offsite-stack"; import { BackupStack } from "../lib/backup-stack"; import { RegionalBaselineStack } from "../lib/regional-baseline-stack"; +import { DynamoDbCmkStack } from "../lib/dynamodb-cmk-stack"; const ACCOUNT = "328440206208"; @@ -17,6 +18,16 @@ new AccountBaselineStack(app, "account-baseline", { budgetAlertEmail: "adam@seahavenind.com", }); +// ── Shared DynamoDB CMK (INFRA-95 / M-3) ───────────────────────────────────── +// Dedicated, standalone stack so the customer-managed key for sensitive +// finance/PII DynamoDB tables is an independent shared dependency for the owning +// app repos (payments-dashboard, procurement-ingest, exec-aide). Its ARN is +// published to SSM (/seahaven/dynamodb/cmk-arn) for those stacks to consume. +new DynamoDbCmkStack(app, "dynamodb-cmk", { + stackName: "seahaven-dynamodb-cmk", + env: { account: ACCOUNT, region: "us-east-1" }, +}); + // ── Secondary-region baselines (INFRA-16, INFRA-91) ────────────────────────── // The us-east-1 baseline above is region-pinned by design. These stacks extend // a minimal detective/logging footprint into the secondary regions, codifying diff --git a/lib/dynamodb-cmk-stack.ts b/lib/dynamodb-cmk-stack.ts new file mode 100644 index 0000000..b2897d4 --- /dev/null +++ b/lib/dynamodb-cmk-stack.ts @@ -0,0 +1,105 @@ +import * as cdk from "aws-cdk-lib"; +import * as kms from "aws-cdk-lib/aws-kms"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as ssm from "aws-cdk-lib/aws-ssm"; +import { Construct } from "constructs"; + +/** + * Shared customer-managed CMK for sensitive DynamoDB tables (INFRA-95 / M-3). + * + * Replaces the default AWS-owned key on tables holding FINANCIAL / PII data so + * that the encryption key is account-controlled, rotated, and auditable: + * `PaymentsDashboard`, `purchase-orders`, `exec-aide`, `WorkOrders`, + * `WorkOrderComments`. + * + * Lives in its OWN CloudFormation stack (not the account-baseline stack) so the + * key is an independent, shared dependency for three separate owning repos + * (payments-dashboard SAM, procurement-ingest CDK, exec-aide CDK) and so its + * deploys never contend with the account-baseline stack. + * + * Key-policy design (cross-reviewed by GPT-4.1, 2026-06-08): + * - The consuming Lambda/Fargate roles carry CFN hash suffixes that change on + * replacement, and `purchase-orders` has CROSS-STACK readers (seahaven-bot's + * po-sync + wo-po-lookup). Hardcoding role ARNs in the key policy would be + * fragile and would silently break access on any role replacement. + * - Instead this uses the delegation-to-IAM pattern: the key policy authorizes + * the whole account to use the key, but ONLY when the request reaches KMS via + * DynamoDB in us-east-1 (`kms:ViaService`). Actual authZ is then gated by each + * consumer role's identity policy, which must separately grant + * `kms:Decrypt`/`kms:GenerateDataKey`/`kms:DescribeKey` on this CMK ARN. + * - `kms:CreateGrant` is in the resource policy because DynamoDB SSE-KMS + * operates through a grant: when a table is associated with the CMK, DynamoDB + * calls CreateGrant on behalf of the deploy principal. The deploy roles also + * need `kms:CreateGrant` in their identity policy — the CDK exec role has + * AdministratorAccess; the SAM `github-cfn-execution-role` was granted it + * (scoped `kms:GrantIsForAWSResource:true`) for the PaymentsDashboard + * conversion. + * - `kms:CallerAccount` is kept as cheap defense-in-depth against a future + * cross-account confused-deputy on the same key. + * - `kms:ReEncrypt*` deliberately omitted — DynamoDB SSE-KMS never calls it + * (uses GenerateDataKey + Decrypt); CMK rotation re-encryption is handled by + * AWS via the grant. + * + * The key ARN is published to SSM (`/seahaven/dynamodb/cmk-arn`) so consumer + * stacks in other repos can resolve it without a hard CFN cross-stack export. + * + * removalPolicy RETAIN — deleting this CMK while any table still has data + * encrypted under it would make that data permanently unrecoverable. + */ +export class DynamoDbCmkStack extends cdk.Stack { + public readonly key: kms.Key; + + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const account = this.account; + const region = this.region; + + this.key = new kms.Key(this, "Key", { + alias: "seahaven-dynamodb", + description: + "SSE for sensitive DynamoDB tables (PaymentsDashboard, purchase-orders, exec-aide, WorkOrders, WorkOrderComments) — INFRA-95/M-3", + enableKeyRotation: true, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + + // Account-wide use of the key, but ONLY via DynamoDB in this region. The + // per-role identity grants (in each consumer stack) are what actually scope + // which principals can read/write the encrypted tables. + this.key.addToResourcePolicy( + new iam.PolicyStatement({ + sid: "AllowDynamoDbSSEViaService", + effect: iam.Effect.ALLOW, + principals: [new iam.AccountRootPrincipal()], + actions: [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey*", + "kms:DescribeKey", + "kms:CreateGrant", + ], + resources: ["*"], + conditions: { + StringEquals: { + "kms:ViaService": `dynamodb.${region}.amazonaws.com`, + "kms:CallerAccount": account, + }, + }, + }), + ); + + new ssm.StringParameter(this, "CmkArnParam", { + parameterName: "/seahaven/dynamodb/cmk-arn", + stringValue: this.key.keyArn, + description: + "ARN of the shared customer-managed CMK for sensitive DynamoDB tables (INFRA-95/M-3)", + }); + + 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, "DynamoDbCmkArn", { value: this.key.keyArn }); + } +} From e22c4ac005384cb44474e68c17b65bbd8611ce75 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:38:23 -0400 Subject: [PATCH 5/6] Bring Config recorder + channel under IaC via AwsCustomResource (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L1 AWS::Config::ConfigurationRecorder deadlocks the CDK stack (recorder can't complete without a delivery channel; channel can't be created without a recorder — observed 2026-06-01). Fix: three AwsCustomResource nodes call PutConfigurationRecorder → PutDeliveryChannel → StartConfigurationRecorder in sequence. Put* is an idempotent upsert, so the deploy adopts the existing CLI-created recorder and channel without destroying or interrupting them. onDelete stops recording rather than deleting the per-account singleton. New IAM permissions on the custom-resource role (cross-reviewed, GPT-4.1 APPROVE — no BLOCK): config:PutConfigurationRecorder config:PutDeliveryChannel config:StartConfigurationRecorder config:StopConfigurationRecorder iam:PassRole → seahaven-config-recorder-role (service=config) cdk diff shows [+] adds only — no existing resources destroyed or replaced. Removes README note that recorder/channel are CLI-only. Refs: INFRA-17 --- README.md | 16 ++-- lib/detective-controls.ts | 178 +++++++++++++++++++++++++++++++++++--- 2 files changed, 175 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f94b89a..a16c417 100644 --- a/README.md +++ b/README.md @@ -126,19 +126,19 @@ live here); multi-region coverage is a follow-up. |---|---|---|---| | Config delivery bucket | `seahaven-config-328440206208` | H-2 | Private (BPA all), SSE-S3, versioned, TLS-only, 365d lifecycle | | Config recorder role | `seahaven-config-recorder-role` | H-2 | `AWS_ConfigRole` + scoped S3 delivery; **IAM cross-reviewed** | +| Config recorder + channel | `DetectiveControls/ConfigPutRecorder`, `ConfigPutChannel`, `ConfigStartRecorder` | H-2 | `AwsCustomResource` calls `PutConfigurationRecorder` → `PutDeliveryChannel` → `StartConfigurationRecorder` in sequence; idempotent upsert avoids the L1 CFN deadlock (INFRA-17). Custom-resource role cross-reviewed. | | GuardDuty detector | `DetectiveControls/GuardDutyDetector` | H-3 | Findings every 15 min | | Security Hub | `DetectiveControls/SecurityHub` | H-4 | FSBP v1.0.0 + CIS v3.0.0; controls evaluate once Config is recording | | Access Analyzer | `seahaven-account-analyzer` | M-5 | ACCOUNT external-access analyzer (free) | | Monthly budget | `GovernanceToggles/MonthlyCostBudget` (`seahaven-monthly-cost`) | M-10 | $1,200/mo, 80%/100% actual + 100% forecast → adam@seahavenind.com | -**Config recorder + delivery channel are NOT in CloudFormation.** The L1 -`AWS::Config::ConfigurationRecorder` is a stabilizing resource that hangs the -stack: it never reaches `CREATE_COMPLETE` until recording is active, which needs -a delivery channel, which can't be created until the recorder completes — a -deadlock (hit on 2026-06-01). The role + delivery bucket stay in IaC (the role -is cross-reviewed); the recorder/channel are created via CLI (below), referencing -the stack's `ConfigRecorderRoleArn` output and the `seahaven-config-328440206208` -bucket. +**Config recorder + delivery channel are managed by `AwsCustomResource` (INFRA-17).** +The L1 `AWS::Config::ConfigurationRecorder` deadlocks the stack (recorder never +reaches `CREATE_COMPLETE` without a delivery channel; channel can't be created +without a recorder — hit 2026-06-01). The custom resource sidesteps this by +calling the Config SDK directly: `Put*` is an upsert, so the deploy adopts the +existing CLI-created recorder and channel without destroying them. Active +recording is never interrupted. ### CLI-applied governance toggles (no CloudFormation resource) diff --git a/lib/detective-controls.ts b/lib/detective-controls.ts index 0a0762c..8bc164f 100644 --- a/lib/detective-controls.ts +++ b/lib/detective-controls.ts @@ -4,6 +4,7 @@ import * as iam from "aws-cdk-lib/aws-iam"; import * as guardduty from "aws-cdk-lib/aws-guardduty"; import * as securityhub from "aws-cdk-lib/aws-securityhub"; import * as accessanalyzer from "aws-cdk-lib/aws-accessanalyzer"; +import * as cr from "aws-cdk-lib/custom-resources"; import { Construct } from "constructs"; /** @@ -112,15 +113,169 @@ export class DetectiveControls extends Construct { }) ); - // NOTE — the Config recorder + delivery channel are provisioned via CLI, - // not CloudFormation. The L1 AWS::Config::ConfigurationRecorder is a - // stabilizing resource that will not reach CREATE_COMPLETE until recording - // is active, which needs a delivery channel; the delivery channel cannot be - // created until the recorder resource completes — a deadlock that hangs the - // stack indefinitely (observed 2026-06-01). The role + delivery bucket above - // stay in IaC (the role is the cross-reviewed IAM); the recorder/channel are - // created with the commands documented in the README, referencing this role - // ARN and bucket name (exported below). + // ── AWS Config recorder + delivery channel (INFRA-17) ────────────────── + // + // The L1 AWS::Config::ConfigurationRecorder is a stabilizing resource that + // deadlocks the stack: it never reaches CREATE_COMPLETE until recording is + // active, which requires a delivery channel, which can't be created until + // the recorder is complete (observed 2026-06-01). + // + // Fix: an AwsCustomResource calls the Config SDK directly — Put* is an + // upsert, so the deploy converges the existing CLI-created recorder/channel + // without destroying and recreating them, and active recording is never + // interrupted. Sequence: PutConfigurationRecorder → PutDeliveryChannel → + // StartConfigurationRecorder. + // + // onDelete stops recording (rather than deleting the recorder, which is a + // per-account singleton — deleting it via CFN would wipe all Config history). + // + // IAM additions on the custom-resource role (MANDATORY cross-reviewed per + // CLAUDE.md — see PR description for cross-review output): + // config:PutConfigurationRecorder + // config:PutDeliveryChannel + // config:StartConfigurationRecorder + // config:StopConfigurationRecorder + // iam:PassRole (scoped to the recorder role) + + // Custom-resource role. Principle of least privilege: only the four Config + // actions + PassRole for the recorder role. + const configCustomResourceRole = new iam.Role( + this, + "ConfigCustomResourceRole", + { + roleName: "seahaven-config-custom-resource-role", + assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole" + ), + ], + inlinePolicies: { + ConfigRecorderAdoption: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + sid: "ConfigRecorderManage", + effect: iam.Effect.ALLOW, + actions: [ + "config:PutConfigurationRecorder", + "config:PutDeliveryChannel", + "config:StartConfigurationRecorder", + "config:StopConfigurationRecorder", + ], + // Config recorder/channel are account-level singletons with no + // ARN in resource policies — the API only accepts "*" here. + resources: ["*"], + }), + new iam.PolicyStatement({ + sid: "PassRecorderRole", + effect: iam.Effect.ALLOW, + actions: ["iam:PassRole"], + // Scoped to exactly the recorder role this stack manages. + resources: [recorderRole.roleArn], + conditions: { + StringEquals: { + "iam:PassedToService": "config.amazonaws.com", + }, + }, + }), + ], + }), + }, + } + ); + + // SDK call payloads — defined once, reused for onCreate + onUpdate so both + // paths converge identically (Put* is idempotent/upsert). + const putRecorderCall: cr.AwsSdkCall = { + service: "ConfigService", + action: "putConfigurationRecorder", + parameters: { + ConfigurationRecorder: { + name: "seahaven-config-recorder", + roleARN: recorderRole.roleArn, + recordingGroup: { + allSupported: true, + includeGlobalResourceTypes: true, + }, + }, + }, + // No meaningful response data to extract. + physicalResourceId: cr.PhysicalResourceId.of("seahaven-config-recorder"), + }; + + const putChannelCall: cr.AwsSdkCall = { + service: "ConfigService", + action: "putDeliveryChannel", + parameters: { + DeliveryChannel: { + name: "seahaven-config-delivery", + s3BucketName: configBucket.bucketName, + configSnapshotDeliveryProperties: { + deliveryFrequency: "TwentyFour_Hours", + }, + }, + }, + physicalResourceId: cr.PhysicalResourceId.of( + "seahaven-config-delivery" + ), + }; + + const startRecorderCall: cr.AwsSdkCall = { + service: "ConfigService", + action: "startConfigurationRecorder", + parameters: { + ConfigurationRecorderName: "seahaven-config-recorder", + }, + physicalResourceId: cr.PhysicalResourceId.of( + "seahaven-config-recorder-start" + ), + }; + + // Step 1: put the recorder (upsert). + // policy is not needed — all permissions are on configCustomResourceRole. + const putRecorder = new cr.AwsCustomResource(this, "ConfigPutRecorder", { + onCreate: putRecorderCall, + onUpdate: putRecorderCall, + // onDelete: nothing — do not delete the singleton recorder; recording + // continuity takes priority over stack-delete cleanup. + role: configCustomResourceRole, + installLatestAwsSdk: false, + }); + + // Step 2: put the delivery channel (upsert). Requires recorder to exist. + const putChannel = new cr.AwsCustomResource(this, "ConfigPutChannel", { + onCreate: putChannelCall, + onUpdate: putChannelCall, + role: configCustomResourceRole, + installLatestAwsSdk: false, + }); + putChannel.node.addDependency(putRecorder); + + // Step 3: start recording. Requires both recorder + channel to exist. + // onDelete stops recording rather than deleting the singleton recorder — + // deleting the recorder would wipe Config history and has no CFN resource + // type to reconstruct it anyway. + const startRecorder = new cr.AwsCustomResource( + this, + "ConfigStartRecorder", + { + onCreate: startRecorderCall, + onUpdate: startRecorderCall, + onDelete: { + service: "ConfigService", + action: "stopConfigurationRecorder", + parameters: { + ConfigurationRecorderName: "seahaven-config-recorder", + }, + physicalResourceId: cr.PhysicalResourceId.of( + "seahaven-config-recorder-stop" + ), + }, + role: configCustomResourceRole, + installLatestAwsSdk: false, + } + ); + startRecorder.node.addDependency(putChannel); new cdk.CfnOutput(this, "ConfigRecorderRoleArn", { value: recorderRole.roleArn, @@ -137,8 +292,9 @@ export class DetectiveControls extends Construct { // ────────────────────────────────────────────────────────────────────── // H-4 Security Hub (FSBP + CIS v3.0) // ────────────────────────────────────────────────────────────────────── - // CIS/FSBP controls evaluate against the Config recording set up via CLI; - // no CFN dependency is needed (findings populate once Config is recording). + // CIS/FSBP controls evaluate against the Config recording managed by the + // custom resource above; no CFN dependency needed (findings populate once + // recording is active). const hub = new securityhub.CfnHub(this, "SecurityHub", { enableDefaultStandards: false, controlFindingGenerator: "SECURITY_CONTROL", From cd82b49f17a8498b0868deb1e5b3811e8fdb5cf4 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:44:33 -0400 Subject: [PATCH 6/6] Stabilize CloudTrail log group name to prevent filter detachment (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/account-baseline-stack.ts | 59 ++++++++++++++++++++--------------- lib/cis-monitoring.ts | 26 ++++++++------- 2 files changed, 48 insertions(+), 37 deletions(-) diff --git a/lib/account-baseline-stack.ts b/lib/account-baseline-stack.ts index 7579625..7d960a0 100644 --- a/lib/account-baseline-stack.ts +++ b/lib/account-baseline-stack.ts @@ -154,6 +154,38 @@ export class AccountBaselineStack extends cdk.Stack { 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. @@ -174,34 +206,10 @@ export class AccountBaselineStack extends cdk.Stack { includeGlobalServiceEvents: true, enableFileValidation: true, sendToCloudWatchLogs: true, - cloudWatchLogsRetention: logs.RetentionDays.ONE_YEAR, + cloudWatchLogGroup: trailLogGroup, managementEvents: cloudtrail.ReadWriteType.ALL, }); - // ── 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"); - - // CMK-encrypt the Trail's CloudWatch Logs group in place. The L2 Trail - // construct owns this group (path Trail/LogGroup) and does not expose an - // encryptionKey prop for it, so we set KmsKeyId via escape hatch. This keeps - // the same logical ID and physical name, so it is additive (no replacement) - // and the CIS Section 4 metric filters that import the group by name (H-1) - // keep working, and the live audit trail is never disrupted. - // - // Gated by context `encryptTrailLogGroup` (default true) so the CMK can be - // rolled out and smoke-tested on a low-risk Lambda log group first, before - // applying it to the most-sensitive CloudTrail group (INFRA-96 step 3). - const encryptTrailLogGroup = - this.node.tryGetContext("encryptTrailLogGroup") !== "false"; - if (encryptTrailLogGroup && trail.logGroup) { - const cfnTrailLogGroup = trail.logGroup.node - .defaultChild as logs.CfnLogGroup; - cfnTrailLogGroup.kmsKeyId = logsKey.key.keyArn; - } - // ── Day 1 detective layer + governance toggles ── // Config (H-2), GuardDuty (H-3), Security Hub (H-4), Access Analyzer (M-5). new DetectiveControls(this, "DetectiveControls"); @@ -216,6 +224,7 @@ export class AccountBaselineStack extends cdk.Stack { // SES bounce/complaint config set (M-13). new CisMonitoring(this, "CisMonitoring", { alarmEmail: props.budgetAlertEmail, + trailLogGroup, }); new FlowLogs(this, "FlowLogs"); new SesMonitoring(this, "SesMonitoring"); diff --git a/lib/cis-monitoring.ts b/lib/cis-monitoring.ts index 3d9d0fb..af41368 100644 --- a/lib/cis-monitoring.ts +++ b/lib/cis-monitoring.ts @@ -15,15 +15,14 @@ import { Construct } from "constructs"; * (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 trail log group is injected via props (INFRA-19). The previous approach + * imported the group by a hardcoded CDK-generated name constant — if the Trail + * or log group was ever recreated the generated suffix would change and all 15 + * filters would silently detach. The caller now creates an explicit LogGroup with + * a stable name and passes the CDK object here. */ -// 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; @@ -142,6 +141,13 @@ const CIS_CONTROLS: CisControl[] = [ export interface CisMonitoringProps { /** Email subscribed to the CIS alarm topic. */ readonly alarmEmail: string; + /** + * The CloudTrail CloudWatch Logs group. Must be the explicit stable-named + * LogGroup created in account-baseline-stack.ts (INFRA-19) — not an imported + * reference, so the metric filters are bound to the CDK object rather than a + * hardcoded generated name. + */ + readonly trailLogGroup: logs.ILogGroup; } export class CisMonitoring extends Construct { @@ -180,11 +186,7 @@ export class CisMonitoring extends Construct { }); topic.addSubscription(new subscriptions.EmailSubscription(props.alarmEmail)); - const logGroup = logs.LogGroup.fromLogGroupName( - this, - "TrailLogGroup", - TRAIL_LOG_GROUP_NAME - ); + const logGroup = props.trailLogGroup; for (const c of CIS_CONTROLS) { const mf = new logs.MetricFilter(this, `${c.id}Filter`, {