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 * as cr from "aws-cdk-lib/custom-resources"; 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], }) ); // ── 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, }); // ────────────────────────────────────────────────────────────────────── // 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 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", 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, }); } }