SecurityHubFactory.java
package com.cloudforgeci.api.observability;
import com.cloudforgeci.api.core.annotation.BaseFactory;
import com.cloudforge.core.annotation.DeploymentContext;
import software.amazon.awscdk.Stack;
import software.amazon.awscdk.customresources.AwsCustomResource;
import software.amazon.awscdk.customresources.AwsCustomResourcePolicy;
import software.amazon.awscdk.customresources.AwsSdkCall;
import software.amazon.awscdk.customresources.PhysicalResourceId;
import software.amazon.awscdk.customresources.SdkCallsPolicyOptions;
import software.amazon.awscdk.services.securityhub.CfnStandard;
import software.constructs.Construct;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
/**
* Factory for AWS Security Hub and its standards subscriptions.
*
* <p>Security Hub is an account/Region singleton, and {@code securityhub:EnableSecurityHub} is
* not idempotent -- it throws {@code ResourceConflictException} when the account is already
* subscribed. The declarative {@code AWS::SecurityHub::Hub} CloudFormation resource has the same
* failure mode: a second CloudForgeCI stack in the same account/Region would fail its deploy
* trying to create a second hub. This enables the hub via an {@link AwsCustomResource} calling
* {@code securityhub:EnableSecurityHub} directly and ignores that specific conflict, so a second
* stack's deploy adopts the existing hub instead of failing. No {@code onDelete}: another stack
* may still depend on Security Hub, so deleting this stack must not disable it account-wide.
*
* <p>Standards subscriptions ({@code CfnStandard}) stay declarative CloudFormation resources --
* each standard's ARN is stack-specific, so redundant subscriptions across stacks are a much
* narrower, lower-frequency risk than the hub itself, and CloudFormation's native resource
* lifecycle (proper update/delete, drift detection) is worth keeping here where it's available.
*
* <p><b>Standard versions are config, not hardcoded</b> -- AWS periodically ships new standard
* versions (CIS has shipped 1.2.0, 1.4.0, 3.0.0, and 5.0.0; PCI DSS moved to 4.0.1 after the PCI
* Council retired the prior version), so a version bump here is a {@code securityHub*Version}
* config change, not a code change. Defaults are current as of this writing -- verify against
* {@code securityhub:DescribeStandards} if in doubt.
*/
public class SecurityHubFactory extends BaseFactory {
private static final Logger LOG = Logger.getLogger(SecurityHubFactory.class.getName());
// CIS AWS Foundations Benchmark: only v1.2.0 uses the legacy global (no region) ruleset ARN.
// v1.4.0/3.0.0/5.0.0 are region-scoped standards ARNs, like FSBP and PCI-DSS below.
private static final String CIS_LEGACY_STANDARD_ARN =
"arn:aws:securityhub:::ruleset/cis-aws-foundations-benchmark/v/1.2.0";
private static final String CIS_STANDARD_ARN_TEMPLATE =
"arn:aws:securityhub:%s::standards/cis-aws-foundations-benchmark/v/%s";
private static final String FSBP_STANDARD_ARN_TEMPLATE =
"arn:aws:securityhub:%s::standards/aws-foundational-security-best-practices/v/%s";
private static final String PCI_DSS_STANDARD_ARN_TEMPLATE =
"arn:aws:securityhub:%s::standards/pci-dss/v/%s";
@DeploymentContext("securityHubEnabled")
private Boolean securityHubEnabled;
@DeploymentContext("securityHubCisEnabled")
private Boolean securityHubCisEnabled;
@DeploymentContext("securityHubCisVersion")
private String securityHubCisVersion;
@DeploymentContext("securityHubAwsFoundationalEnabled")
private Boolean securityHubAwsFoundationalEnabled;
@DeploymentContext("securityHubFsbpVersion")
private String securityHubFsbpVersion;
@DeploymentContext("securityHubPciDssEnabled")
private Boolean securityHubPciDssEnabled;
@DeploymentContext("securityHubPciDssVersion")
private String securityHubPciDssVersion;
/** @param scope parent construct
* @param id construct ID */
public SecurityHubFactory(Construct scope, String id) {
super(scope, id);
}
/** Resolves whether Security Hub is required, then enables the account/Region hub (adopting
* an existing one if another stack already created it) and subscribes it to each enabled
* standard. */
@Override
public void create() {
if (securityHubEnabled == null) {
var securityProfileConfig = ctx.securityProfileConfig.get().orElse(null);
if (securityProfileConfig != null) {
// isSecurityHubEnabled() already applies the compliance-matrix requirement
// (SOC2/PCI-DSS) and then the profile default -- no need to duplicate that logic
// here.
securityHubEnabled = securityProfileConfig.isSecurityHubEnabled();
LOG.info("Security Hub inherited from security profile: " + securityHubEnabled);
}
}
if (!Boolean.TRUE.equals(securityHubEnabled)) {
LOG.info("Security Hub disabled");
return;
}
String account = Stack.of(this).getAccount();
String region = Stack.of(this).getRegion();
AwsSdkCall enableCall = AwsSdkCall.builder()
.service("SecurityHub")
.action("enableSecurityHub")
.parameters(Map.of("EnableDefaultStandards", false))
.physicalResourceId(PhysicalResourceId.of("securityhub-enable-" + account + "-" + region))
.ignoreErrorCodesMatching("ResourceConflictException")
.build();
AwsCustomResource hub = AwsCustomResource.Builder.create(this, "SecurityHub")
.onCreate(enableCall)
.onUpdate(enableCall)
.policy(AwsCustomResourcePolicy.fromSdkCalls(
SdkCallsPolicyOptions.builder()
.resources(List.of("*"))
.build()
))
.build();
if (Boolean.TRUE.equals(securityHubCisEnabled)) {
String cisArn = "1.2.0".equals(securityHubCisVersion)
? CIS_LEGACY_STANDARD_ARN
: String.format(CIS_STANDARD_ARN_TEMPLATE, region, securityHubCisVersion);
addStandard("CisStandard", cisArn, hub);
}
if (Boolean.TRUE.equals(securityHubAwsFoundationalEnabled)) {
addStandard("FsbpStandard",
String.format(FSBP_STANDARD_ARN_TEMPLATE, region, securityHubFsbpVersion), hub);
}
if (Boolean.TRUE.equals(securityHubPciDssEnabled)) {
addStandard("PciDssStandard",
String.format(PCI_DSS_STANDARD_ARN_TEMPLATE, region, securityHubPciDssVersion), hub);
}
LOG.info("Security Hub enabled (hub + standards subscriptions)");
}
/** Subscribes the hub to one standard by ARN, depending on the hub's own enablement so the
* subscription can't synthesize before it. */
private void addStandard(String id, String standardsArn, AwsCustomResource hub) {
CfnStandard standard = CfnStandard.Builder.create(this, id)
.standardsArn(standardsArn)
.build();
standard.getNode().addDependency(hub);
}
}