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] [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"); + } +}