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