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 for the isolated external-dev account. * * Adapted from seahaven-account-baseline/lib/detective-controls.ts. Provides: * AWS Config recorder + delivery channel (CIS 3.3/3.5) * GuardDuty detector * Security Hub with AWS FSBP + CIS v3.0 standards * IAM Access Analyzer (account-scoped external-access analyzer) * * Inspector2 has no CloudFormation enable resource and is a documented * post-deploy CLI step (see README), matching the prod baseline. * * Scope is us-east-1 only (the account is region-locked by SCP). No local * CloudTrail and no CIS Section-4 metric-filter alarms: the management-account * organization trail (seahaven-org-trail) already captures this account's * events centrally, and the Security Hub CIS standard below evaluates the CIS * controls against AWS Config without needing a local trail log group. */ export class DetectiveControls extends Construct { constructor(scope: Construct, id: string) { super(scope, id); const stack = cdk.Stack.of(this); // ────────────────────────────────────────────────────────────────────── // 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-extdev-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. (IAM change — cross-review per // CLAUDE.md; pattern replicated verbatim from the cross-reviewed prod // baseline.) const recorderRole = new iam.Role(this, "ConfigRecorderRole", { roleName: "seahaven-extdev-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 ───────────────────────────── // // 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. Fix (from the prod baseline): an // AwsCustomResource calls the Config SDK directly — Put* is an upsert. // Sequence: PutConfigurationRecorder -> PutDeliveryChannel -> // StartConfigurationRecorder. onDelete stops (does not delete) the // per-account-singleton recorder. const configCustomResourceRole = new iam.Role( this, "ConfigCustomResourceRole", { roleName: "seahaven-extdev-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"], resources: [recorderRole.roleArn], conditions: { StringEquals: { "iam:PassedToService": "config.amazonaws.com", }, }, }), ], }), }, } ); const putRecorderCall: cr.AwsSdkCall = { service: "ConfigService", action: "putConfigurationRecorder", parameters: { ConfigurationRecorder: { name: "seahaven-extdev-config-recorder", roleARN: recorderRole.roleArn, recordingGroup: { allSupported: true, includeGlobalResourceTypes: true, }, }, }, physicalResourceId: cr.PhysicalResourceId.of( "seahaven-extdev-config-recorder" ), }; const putChannelCall: cr.AwsSdkCall = { service: "ConfigService", action: "putDeliveryChannel", parameters: { DeliveryChannel: { name: "seahaven-extdev-config-delivery", s3BucketName: configBucket.bucketName, configSnapshotDeliveryProperties: { deliveryFrequency: "TwentyFour_Hours", }, }, }, physicalResourceId: cr.PhysicalResourceId.of( "seahaven-extdev-config-delivery" ), }; const startRecorderCall: cr.AwsSdkCall = { service: "ConfigService", action: "startConfigurationRecorder", parameters: { ConfigurationRecorderName: "seahaven-extdev-config-recorder", }, physicalResourceId: cr.PhysicalResourceId.of( "seahaven-extdev-config-recorder-start" ), }; const putRecorder = new cr.AwsCustomResource(this, "ConfigPutRecorder", { onCreate: putRecorderCall, onUpdate: putRecorderCall, role: configCustomResourceRole, installLatestAwsSdk: false, }); const putChannel = new cr.AwsCustomResource(this, "ConfigPutChannel", { onCreate: putChannelCall, onUpdate: putChannelCall, role: configCustomResourceRole, installLatestAwsSdk: false, }); putChannel.node.addDependency(putRecorder); const startRecorder = new cr.AwsCustomResource(this, "ConfigStartRecorder", { onCreate: startRecorderCall, onUpdate: startRecorderCall, onDelete: { service: "ConfigService", action: "stopConfigurationRecorder", parameters: { ConfigurationRecorderName: "seahaven-extdev-config-recorder", }, physicalResourceId: cr.PhysicalResourceId.of( "seahaven-extdev-config-recorder-stop" ), }, role: configCustomResourceRole, installLatestAwsSdk: false, }); startRecorder.node.addDependency(putChannel); new cdk.CfnOutput(this, "ConfigRecorderRoleArn", { value: recorderRole.roleArn, }); // ────────────────────────────────────────────────────────────────────── // GuardDuty // ────────────────────────────────────────────────────────────────────── new guardduty.CfnDetector(this, "GuardDutyDetector", { enable: true, findingPublishingFrequency: "FIFTEEN_MINUTES", }); // ────────────────────────────────────────────────────────────────────── // Security Hub (FSBP + CIS v3.0) — the CIS coverage substitute for the // dropped Section-4 metric alarms; evaluates against Config, not a trail. // ────────────────────────────────────────────────────────────────────── 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); // ────────────────────────────────────────────────────────────────────── // IAM Access Analyzer (free, account-scoped external-access) // ────────────────────────────────────────────────────────────────────── new accessanalyzer.CfnAnalyzer(this, "AccountAnalyzer", { analyzerName: "seahaven-extdev-account-analyzer", type: "ACCOUNT", }); // Inspector2 (EC2 + ECR + Lambda) has NO CloudFormation resource for // *enabling* the service — it is a post-deploy CLI step, documented in the // README runbook (same as the prod baseline): // aws inspector2 enable --resource-types EC2 ECR LAMBDA \ // --account-ids new cdk.CfnOutput(this, "ConfigBucketName", { value: configBucket.bucketName, }); } }