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) * * Serves both the management-account baseline (namePrefix "seahaven") and * member-account baselines (e.g. "seahaven-extdev") — physical resource names * are prefix-parameterized, structure is identical. * * Scope is us-east-1 only — all workloads live here (Adam's call, Day 1). * Multi-region coverage is a documented follow-up. */ export interface DetectiveControlsProps { /** * Physical-name prefix for account-scoped resources (bucket, roles, recorder, * delivery channel, analyzer). Also baked into the custom resources' * PhysicalResourceId strings — changing it on a deployed stack REPLACES the * custom resources; keep it stable per account. */ readonly namePrefix: string; /** * Create the account-local GuardDuty detector + Security Hub hub. Default * TRUE (pre-delegation accounts own these). Set FALSE for accounts enrolled * by the org delegated admin (post 2026-07-14): the org creates the * detector/hub itself and a CFN-owned duplicate fails (one per account). * ALWAYS created regardless of this flag (security review SH-DEV-001 / * SH-DEVBASE-002): Security Hub STANDARDS (org AutoEnableStandards is NONE — * DEFAULT would enroll legacy CIS v1.2.0, so IaC owns FSBP + CIS v3.0; * CfnStandard attaches fine to an org-enabled hub), the account Access * Analyzer (the ORGANIZATION analyzer treats the whole org as trusted and * cannot flag intra-org exposure — e.g. to the isolated contractor account; * both analyzer types coexist, verified live), and the Config recorder * (recorders stay per-account, delegation never makes one). */ readonly localDetectiveServices?: boolean; } export class DetectiveControls extends Construct { constructor(scope: Construct, id: string, props: DetectiveControlsProps) { super(scope, id); const stack = cdk.Stack.of(this); const prefix = props.namePrefix; // ────────────────────────────────────────────────────────────────────── // 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: `${prefix}-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: `${prefix}-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: `${prefix}-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: `${prefix}-config-recorder`, roleARN: recorderRole.roleArn, recordingGroup: { allSupported: true, includeGlobalResourceTypes: true, }, }, }, // No meaningful response data to extract. physicalResourceId: cr.PhysicalResourceId.of(`${prefix}-config-recorder`), }; const putChannelCall: cr.AwsSdkCall = { service: "ConfigService", action: "putDeliveryChannel", parameters: { DeliveryChannel: { name: `${prefix}-config-delivery`, s3BucketName: configBucket.bucketName, configSnapshotDeliveryProperties: { deliveryFrequency: "TwentyFour_Hours", }, }, }, physicalResourceId: cr.PhysicalResourceId.of( `${prefix}-config-delivery` ), }; const startRecorderCall: cr.AwsSdkCall = { service: "ConfigService", action: "startConfigurationRecorder", parameters: { ConfigurationRecorderName: `${prefix}-config-recorder`, }, physicalResourceId: cr.PhysicalResourceId.of( `${prefix}-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: `${prefix}-config-recorder`, }, physicalResourceId: cr.PhysicalResourceId.of( `${prefix}-config-recorder-stop` ), }, role: configCustomResourceRole, installLatestAwsSdk: false, } ); startRecorder.node.addDependency(putChannel); new cdk.CfnOutput(this, "ConfigRecorderRoleArn", { value: recorderRole.roleArn, }); // ────────────────────────────────────────────────────────────────────── // H-3 GuardDuty detector + H-4 Security Hub hub — skipped when the org // delegated admin owns them (localDetectiveServices: false). Standards and // the analyzer below are created UNCONDITIONALLY (see props doc). // ────────────────────────────────────────────────────────────────────── const localDetection = props.localDetectiveServices ?? true; let hub: securityhub.CfnHub | undefined; if (localDetection) { 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). 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 ); // When the hub is org-managed (localDetection false) it already exists // before this stack deploys (enrollment is a deploy precondition), so the // standards attach without a CFN dependency. const fsbp = new securityhub.CfnStandard(this, "StandardFSBP", { standardsArn: fsbpArn, }); if (hub) { fsbp.node.addDependency(hub); } const cis = new securityhub.CfnStandard(this, "StandardCIS", { standardsArn: cisArn, }); if (hub) { cis.node.addDependency(hub); } // ────────────────────────────────────────────────────────────────────── // M-5 IAM Access Analyzer (free, account-scoped external-access) — // always created: the ORGANIZATION analyzer cannot flag intra-org // exposure (SH-DEVBASE-002). // ────────────────────────────────────────────────────────────────────── new accessanalyzer.CfnAnalyzer(this, "AccountAnalyzer", { analyzerName: `${prefix}-account-analyzer`, type: "ACCOUNT", }); new cdk.CfnOutput(this, "ConfigBucketName", { value: configBucket.bucketName, }); } }