seahaven-org-baseline/lib/detective-controls.ts
Adam Moussa e22c4ac005
Bring Config recorder + channel under IaC via AwsCustomResource (#22)
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
2026-06-10 14:38:23 -04:00

347 lines
14 KiB
TypeScript

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