mirror of
https://github.com/Sea-Haven-Industries/seahaven-org-baseline.git
synced 2026-09-30 08:03:19 +00:00
* Add seahaven-dev member baseline with org-managed detection Account 710827005802 (internal dev/staging) is the first account born after delegation: GuardDuty/Security Hub enroll it via the org admin, so DetectiveControls gains a localDetectiveServices flag (default true — zero diff on the three deployed consumers, verified) and the dev instance sets orgManagedDetection to skip the colliding local detector/hub/analyzer. Default VPC kept and flow-logged (dev runs real workloads). Enrollment verified Enabled in both services before this commit. * Fix Phase-4 review findings: standards + analyzer stay CFN-owned SH-DEV-001: org AutoEnableStandards DEFAULT gave dev legacy CIS v1.2.0 and nothing owned CIS v3.0 — org config set to NONE, standards are now unconditional in DetectiveControls (attach fine to an org-enabled hub), legacy ruleset disabled in dev. SH-DEVBASE-002: the ORGANIZATION analyzer treats the whole org as trusted so it cannot flag intra-org exposure — account analyzer restored unconditionally (coexistence verified live). Enrollment comments corrected: manual create-members, the automatic sweep is still unexercised. Zero diff re-verified on all three deployed baseline stacks.
392 lines
16 KiB
TypeScript
392 lines
16 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)
|
|
*
|
|
* 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,
|
|
});
|
|
}
|
|
}
|