diff --git a/README.md b/README.md index 854fbcd..9b12be5 100644 --- a/README.md +++ b/README.md @@ -84,13 +84,66 @@ them with the missing offsite + immutable leg; it does not replace them. it to us-west-2 first; if the copy fails, encrypt it or drop it from the copy. 3. Enable DynamoDB PITR (H-7) on the two tables for between-window recovery. +### Detective controls + budget (audit Day 1) + +Account-level detective layer, in `lib/detective-controls.ts`, plus the cost +budget in `lib/governance-toggles.ts`. **Scope is us-east-1 only** (all workloads +live here); multi-region coverage is a follow-up. + +| Resource | Logical ID | Finding | Notes | +|---|---|---|---| +| 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** | +| 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. + +### CLI-applied governance toggles (no CloudFormation resource) + +These account toggles have no native CloudFormation resource, so they are applied +via CLI and recorded here. Applied 2026-06-01. + +```bash +# M-3 EBS encryption-by-default (new volumes; existing 5 plaintext volumes are H-19-adjacent) +aws ec2 enable-ebs-encryption-by-default --region us-east-1 + +# M-6 Inspector2 (EC2 + Lambda + ECR) +aws inspector2 enable --resource-types EC2 LAMBDA ECR --region us-east-1 + +# M-7 IAM password policy (CIS 1.8/1.9): >=14 chars, full complexity, no reuse of last 24 +aws iam update-account-password-policy \ + --minimum-password-length 14 \ + --require-symbols --require-numbers \ + --require-uppercase-characters --require-lowercase-characters \ + --allow-users-to-change-password --password-reuse-prevention 24 + +# M-11 Activate cost-allocation tags (only activates keys already seen on resources) +aws ce update-cost-allocation-tags-status --cost-allocation-tags-status \ + 'TagKey=Project,Status=Active' 'TagKey=Owner,Status=Active' 'TagKey=Environment,Status=Active' +``` + +**L-8 (billing-metrics preference) is OUTSTANDING — console only.** Enabling the +CloudWatch `EstimatedCharges` metric in us-east-1 requires turning on *Receive +Billing Alerts* under Billing → Billing preferences; there is no public API/CLI. +The M-10 budget already provides cost alerting independent of that metric, so +this only affects the legacy `AWS-MonthlyBilling` CloudWatch alarm (L-8). + ## Roadmap (same stack) -Account-level detective controls with no current home, to be added to the -`account-baseline` stack: AWS Config (H-2), GuardDuty (H-3), Security Hub (H-4), -IAM Access Analyzer (M-5), Inspector2 (M-6). Backup phase 2: expand past the -phase-1 set via tag-based selection and graduate the offsite vault to compliance -mode. +Detective layer multi-region expansion (GuardDuty/Config/Security Hub beyond +us-east-1). H-1: CIS Section 4 metric filters/alarms onto the CloudTrail log +group. Backup phase 2: expand past the phase-1 set via tag-based selection and +graduate the offsite vault to compliance mode. ## Deploy @@ -123,3 +176,17 @@ aws backup start-backup-job --backup-vault-name seahaven-primary \ --iam-role-arn arn:aws:iam::328440206208:role/seahaven-backup-service-role aws backup list-copy-jobs --region us-west-2 # copy to offsite present + COMPLETED ``` + +Detective layer + governance (Day 1): + +``` +aws configservice describe-configuration-recorder-status # recording: true +aws guardduty list-detectors # one detector id +aws securityhub get-enabled-standards # FSBP + CIS v3.0.0 +aws accessanalyzer list-analyzers # seahaven-account-analyzer ACTIVE +aws inspector2 batch-get-account-status --region us-east-1 # ec2/ecr/lambda ENABLED +aws iam get-account-password-policy # length 14, reuse 24 +aws ec2 get-ebs-encryption-by-default --region us-east-1 # EbsEncryptionByDefault: true +aws budgets describe-budgets --account-id 328440206208 # seahaven-monthly-cost $1,200 +aws ce list-cost-allocation-tags --status Active # Project/Owner/Environment Active +``` diff --git a/bin/app.ts b/bin/app.ts index 5a7d9ff..72de6ac 100644 --- a/bin/app.ts +++ b/bin/app.ts @@ -10,6 +10,8 @@ const app = new cdk.App(); new AccountBaselineStack(app, "account-baseline", { stackName: "seahaven-account-baseline", env: { account: "328440206208", region: "us-east-1" }, + monthlyBudgetUsd: 1200, + budgetAlertEmail: "adam@seahavenind.com", }); // AWS Backup (audit C-7). Offsite vault (us-west-2) must exist before the diff --git a/lib/account-baseline-stack.ts b/lib/account-baseline-stack.ts index e331e7d..f6fab2a 100644 --- a/lib/account-baseline-stack.ts +++ b/lib/account-baseline-stack.ts @@ -5,6 +5,8 @@ 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 { DetectiveControls } from "./detective-controls"; +import { GovernanceToggles } from "./governance-toggles"; /** * Account-level security baseline for Sea Haven (account 328440206208). @@ -18,8 +20,15 @@ import { Construct } from "constructs"; * Future residents (same stack): AWS Config (H-2), GuardDuty (H-3), * Security Hub (H-4), IAM Access Analyzer (M-5), Inspector2 (M-6). */ +export interface AccountBaselineStackProps extends cdk.StackProps { + /** Monthly cost budget ceiling in USD (M-10). */ + readonly monthlyBudgetUsd: number; + /** Email for budget threshold alerts (M-10). */ + readonly budgetAlertEmail: string; +} + export class AccountBaselineStack extends cdk.Stack { - constructor(scope: Construct, id: string, props?: cdk.StackProps) { + constructor(scope: Construct, id: string, props: AccountBaselineStackProps) { super(scope, id, props); const trailName = "seahaven-org-trail"; @@ -125,6 +134,15 @@ export class AccountBaselineStack extends cdk.Stack { managementEvents: cloudtrail.ReadWriteType.ALL, }); + // ── Day 1 detective layer + governance toggles ── + // Config (H-2), GuardDuty (H-3), Security Hub (H-4), Access Analyzer (M-5). + new DetectiveControls(this, "DetectiveControls"); + // Monthly cost budget (M-10). Other governance toggles are CLI + documented. + new GovernanceToggles(this, "GovernanceToggles", { + monthlyLimitUsd: props.monthlyBudgetUsd, + alertEmail: props.budgetAlertEmail, + }); + cdk.Tags.of(this).add("Project", "account-baseline"); cdk.Tags.of(this).add("Owner", "adam@seahavenind.com"); cdk.Tags.of(this).add("Environment", "prod"); diff --git a/lib/detective-controls.ts b/lib/detective-controls.ts new file mode 100644 index 0000000..0a0762c --- /dev/null +++ b/lib/detective-controls.ts @@ -0,0 +1,191 @@ +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 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 { Construct } from "constructs"; + +/** + * Account-level detective controls (audit Day 1). + * + * Closes: + * H-2 AWS Config recorder + delivery channel (CIS 3.3/3.5) + * H-3 GuardDuty detector + * H-4 Security Hub with AWS FSBP + CIS v3.0 standards + * M-5 IAM Access Analyzer (account-scoped external-access analyzer) + * + * Scope is us-east-1 only — all workloads live here (Adam's call, Day 1). + * Multi-region coverage is a documented follow-up. + */ +export class DetectiveControls extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + + const stack = cdk.Stack.of(this); + + // ────────────────────────────────────────────────────────────────────── + // H-2 AWS Config + // ────────────────────────────────────────────────────────────────────── + + // Delivery bucket for Config snapshots/history. Private, TLS-only, + // versioned, SSE-S3 (Config writes here; SSE-S3 avoids a second KMS-grant + // failure mode and is sufficient — CIS does not require a CMK here). + const configBucket = new s3.Bucket(this, "ConfigBucket", { + bucketName: `seahaven-config-${stack.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, + }); + + // Bucket policy that lets the Config service principal verify ownership + // and deliver objects (scoped to this account, owner-full-control ACL). + 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": stack.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/${stack.account}/Config/*`), + ], + conditions: { + StringEquals: { + "s3:x-amz-acl": "bucket-owner-full-control", + "aws:SourceAccount": stack.account, + }, + }, + }) + ); + + // Recorder role — assumed by Config. AWS_ConfigRole grants the read/describe + // permissions Config needs to record every resource type; the inline policy + // grants delivery to the bucket above. **This role is the Day 1 cross-review + // item (IAM change per CLAUDE.md).** + const recorderRole = new iam.Role(this, "ConfigRecorderRole", { + roleName: "seahaven-config-recorder-role", + 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/${stack.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], + }) + ); + + // 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). + + new cdk.CfnOutput(this, "ConfigRecorderRoleArn", { + value: recorderRole.roleArn, + }); + + // ────────────────────────────────────────────────────────────────────── + // H-3 GuardDuty + // ────────────────────────────────────────────────────────────────────── + new guardduty.CfnDetector(this, "GuardDutyDetector", { + enable: true, + findingPublishingFrequency: "FIFTEEN_MINUTES", + }); + + // ────────────────────────────────────────────────────────────────────── + // 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). + const hub = new securityhub.CfnHub(this, "SecurityHub", { + enableDefaultStandards: false, + controlFindingGenerator: "SECURITY_CONTROL", + autoEnableControls: true, + }); + + const fsbpArn = cdk.Arn.format( + { + service: "securityhub", + region: stack.region, + account: "", + resource: "standards", + resourceName: "aws-foundational-security-best-practices/v/1.0.0", + }, + stack + ); + const cisArn = cdk.Arn.format( + { + service: "securityhub", + region: stack.region, + account: "", + resource: "standards", + resourceName: "cis-aws-foundations-benchmark/v/3.0.0", + }, + stack + ); + + 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); + + // ────────────────────────────────────────────────────────────────────── + // M-5 IAM Access Analyzer (free, account-scoped external-access) + // ────────────────────────────────────────────────────────────────────── + new accessanalyzer.CfnAnalyzer(this, "AccountAnalyzer", { + analyzerName: "seahaven-account-analyzer", + type: "ACCOUNT", + }); + + new cdk.CfnOutput(this, "ConfigBucketName", { + value: configBucket.bucketName, + }); + } +} diff --git a/lib/governance-toggles.ts b/lib/governance-toggles.ts new file mode 100644 index 0000000..e1544e6 --- /dev/null +++ b/lib/governance-toggles.ts @@ -0,0 +1,86 @@ +import * as cdk from "aws-cdk-lib"; +import * as budgets from "aws-cdk-lib/aws-budgets"; +import { Construct } from "constructs"; + +export interface GovernanceTogglesProps { + /** Monthly cost budget ceiling in USD. */ + readonly monthlyLimitUsd: number; + /** Email that receives the budget threshold alerts. */ + readonly alertEmail: string; +} + +/** + * Account-level governance toggles that *are* expressible as CloudFormation + * (audit Day 1). + * + * Closes: + * M-10 Monthly AWS Budget with 80% / 100% actual + 100% forecast alerts + * + * The remaining Day 1 governance items have no CloudFormation resource and are + * applied via CLI + documented in the README runbook (Adam's call, Day 1): + * M-6 Inspector2 enable (EC2 + Lambda + ECR) + * M-3 EBS encryption-by-default + * M-7 IAM account password policy + * L-8 Billing-metrics preference (us-east-1) + * M-11 Cost-allocation tag activation + */ +export class GovernanceToggles extends Construct { + constructor(scope: Construct, id: string, props: GovernanceTogglesProps) { + super(scope, id); + + const subscriber = [ + { + subscriptionType: "EMAIL", + address: props.alertEmail, + }, + ]; + + new budgets.CfnBudget(this, "MonthlyCostBudget", { + budget: { + budgetName: "seahaven-monthly-cost", + budgetType: "COST", + timeUnit: "MONTHLY", + budgetLimit: { + amount: props.monthlyLimitUsd, + unit: "USD", + }, + }, + notificationsWithSubscribers: [ + { + notification: { + notificationType: "ACTUAL", + comparisonOperator: "GREATER_THAN", + threshold: 80, + thresholdType: "PERCENTAGE", + }, + subscribers: subscriber, + }, + { + notification: { + notificationType: "ACTUAL", + comparisonOperator: "GREATER_THAN", + threshold: 100, + thresholdType: "PERCENTAGE", + }, + subscribers: subscriber, + }, + { + notification: { + notificationType: "FORECASTED", + comparisonOperator: "GREATER_THAN", + threshold: 100, + thresholdType: "PERCENTAGE", + }, + subscribers: subscriber, + }, + ], + }); + + cdk.Annotations.of(this).addInfo( + "Budget alerts: 80%/100% actual + 100% forecast of $" + + props.monthlyLimitUsd + + " to " + + props.alertEmail + ); + } +}