mirror of
https://github.com/Sea-Haven-Industries/seahaven-account-baseline.git
synced 2026-08-04 16:56:14 +00:00
Merge branch 'main' into dependabot/npm_and_yarn/types/node-25.9.1
This commit is contained in:
commit
727f4d9760
10 changed files with 840 additions and 35 deletions
34
README.md
34
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-…`),
|
||||
|
|
|
|||
40
bin/app.ts
40
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", {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}`)),
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
71
lib/bedrock-logging-regional.ts
Normal file
71
lib/bedrock-logging-regional.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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
105
lib/dynamodb-cmk-stack.ts
Normal 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
69
lib/logs-key.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
189
lib/regional-baseline-stack.ts
Normal file
189
lib/regional-baseline-stack.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue