From e22c4ac005384cb44474e68c17b65bbd8611ce75 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:38:23 -0400 Subject: [PATCH] Bring Config recorder + channel under IaC via AwsCustomResource (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L1 AWS::Config::ConfigurationRecorder deadlocks the CDK stack (recorder can't complete without a delivery channel; channel can't be created without a recorder — observed 2026-06-01). Fix: three AwsCustomResource nodes call PutConfigurationRecorder → PutDeliveryChannel → StartConfigurationRecorder in sequence. Put* is an idempotent upsert, so the deploy adopts the existing CLI-created recorder and channel without destroying or interrupting them. onDelete stops recording rather than deleting the per-account singleton. New IAM permissions on the custom-resource role (cross-reviewed, GPT-4.1 APPROVE — no BLOCK): config:PutConfigurationRecorder config:PutDeliveryChannel config:StartConfigurationRecorder config:StopConfigurationRecorder iam:PassRole → seahaven-config-recorder-role (service=config) cdk diff shows [+] adds only — no existing resources destroyed or replaced. Removes README note that recorder/channel are CLI-only. Refs: INFRA-17 --- README.md | 16 ++-- lib/detective-controls.ts | 178 +++++++++++++++++++++++++++++++++++--- 2 files changed, 175 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f94b89a..a16c417 100644 --- a/README.md +++ b/README.md @@ -126,19 +126,19 @@ live here); multi-region coverage is a follow-up. |---|---|---|---| | 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** | +| Config recorder + channel | `DetectiveControls/ConfigPutRecorder`, `ConfigPutChannel`, `ConfigStartRecorder` | H-2 | `AwsCustomResource` calls `PutConfigurationRecorder` → `PutDeliveryChannel` → `StartConfigurationRecorder` in sequence; idempotent upsert avoids the L1 CFN deadlock (INFRA-17). Custom-resource role 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. +**Config recorder + delivery channel are managed by `AwsCustomResource` (INFRA-17).** +The L1 `AWS::Config::ConfigurationRecorder` deadlocks the stack (recorder never +reaches `CREATE_COMPLETE` without a delivery channel; channel can't be created +without a recorder — hit 2026-06-01). The custom resource sidesteps this by +calling the Config SDK directly: `Put*` is an upsert, so the deploy adopts the +existing CLI-created recorder and channel without destroying them. Active +recording is never interrupted. ### CLI-applied governance toggles (no CloudFormation resource) diff --git a/lib/detective-controls.ts b/lib/detective-controls.ts index 0a0762c..8bc164f 100644 --- a/lib/detective-controls.ts +++ b/lib/detective-controls.ts @@ -4,6 +4,7 @@ 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"; /** @@ -112,15 +113,169 @@ export class DetectiveControls extends Construct { }) ); - // 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). + // ── 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, @@ -137,8 +292,9 @@ export class DetectiveControls extends Construct { // ────────────────────────────────────────────────────────────────────── // 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). + // 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",