Merge branch 'main' into dependabot/npm_and_yarn/types/node-25.9.1

This commit is contained in:
Adam Moussa 2026-06-10 18:06:27 -04:00 • committed by GitHub
commit 727f4d9760
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 840 additions and 35 deletions

View file

@ -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-…`),

View file

@ -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", {

View file

@ -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/<orgId>/*
// 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");

View file

@ -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}`)),
],
});

View file

@ -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-<region>
* - 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,
});
}
}

View file

@ -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`, {

View file

@ -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",

105
lib/dynamodb-cmk-stack.ts Normal file
View file

@ -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 });
}
}

69
lib/logs-key.ts Normal file
View file

@ -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" });
}
}

View file

@ -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");
}
}