diff --git a/README.md b/README.md index 9f2a0f8..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) @@ -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/bin/app.ts b/bin/app.ts index 72de6ac..06564a7 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -4,16 +4,54 @@ 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"; +import { DynamoDbCmkStack } from "../lib/dynamodb-cmk-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", }); +// ── 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 +// 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..7d960a0 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"; @@ -37,6 +38,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 +76,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", @@ -122,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. @@ -132,10 +196,17 @@ 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, - cloudWatchLogsRetention: logs.RetentionDays.ONE_YEAR, + cloudWatchLogGroup: trailLogGroup, managementEvents: cloudtrail.ReadWriteType.ALL, }); @@ -153,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/backup-stack.ts b/lib/backup-stack.ts index 76f126f..a18f28b 100644 --- a/lib/backup-stack.ts +++ b/lib/backup-stack.ts @@ -80,14 +80,87 @@ 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 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, removalPolicy: cdk.RemovalPolicy.RETAIN, + lockConfiguration: { + minRetention: cdk.Duration.days(1), + maxRetention: cdk.Duration.days(2555), + // No `changeableFor` → GOVERNANCE mode, admin-removable (matches live). + }, }); + // 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. @@ -234,6 +307,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/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`, { 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", 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 }); + } +} 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" }); + } +} 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"); + } +}