InspectorFactory.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.iam.Effect;
import software.amazon.awscdk.services.iam.PolicyStatement;
import software.constructs.Construct;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;

/**
 * Factory for Amazon Inspector v2 account enablement.
 *
 * <p>Unlike GuardDuty/Security Hub/Macie, Inspector2 has no CloudFormation-native enablement
 * resource -- {@code inspector2:Enable} is API-only, so this uses an {@link AwsCustomResource}
 * the same way {@code ComplianceFactory}'s Config Recorder starter does.
 *
 * <p>Inspector2's continuous rescanning is the default behavior for an enabled resource type,
 * not a separate opt-in -- there's no API parameter for it, so {@code inspectorContinuousScanning}
 * doesn't map to anything here and is left unread.
 */
public class InspectorFactory extends BaseFactory {

    private static final Logger LOG = Logger.getLogger(InspectorFactory.class.getName());

    private static final List<String> ALL_RESOURCE_TYPES = List.of("EC2", "ECR");

    @DeploymentContext("inspectorEnabled")
    private Boolean inspectorEnabled;

    @DeploymentContext("inspectorEc2Scanning")
    private Boolean inspectorEc2Scanning;

    @DeploymentContext("inspectorEcrScanning")
    private Boolean inspectorEcrScanning;

    /** @param scope parent construct
     *  @param id construct ID */
    public InspectorFactory(Construct scope, String id) {
        super(scope, id);
    }

    /** Resolves whether Inspector is required, then enables it via the account-scoped
     *  {@code inspector2:Enable} SDK call for the currently-selected resource types. */
    @Override
    public void create() {
        if (inspectorEnabled == null) {
            var securityProfileConfig = ctx.securityProfileConfig.get().orElse(null);
            if (securityProfileConfig != null) {
                inspectorEnabled = securityProfileConfig.isInspectorEnabled();
                LOG.info("Inspector inherited from security profile: " + inspectorEnabled);
            }
        }

        if (!Boolean.TRUE.equals(inspectorEnabled)) {
            LOG.info("Inspector disabled");
            return;
        }

        List<String> resourceTypes = new ArrayList<>();
        if (!Boolean.FALSE.equals(inspectorEc2Scanning)) {
            resourceTypes.add("EC2");
        }
        if (!Boolean.FALSE.equals(inspectorEcrScanning)) {
            resourceTypes.add("ECR");
        }
        if (resourceTypes.isEmpty()) {
            LOG.info("Inspector enabled but both EC2 and ECR scanning explicitly disabled -- nothing to enable");
            return;
        }

        String account = Stack.of(this).getAccount();
        String region = Stack.of(this).getRegion();

        // Inspector2:Enable only turns on the resource types listed -- it never turns off a type
        // that was previously enabled and is now absent from the list. Disable exactly the
        // deselected types (the complement of resourceTypes) before re-enabling the selected
        // ones, so flipping e.g. inspectorEcrScanning from true to false disables ECR scanning
        // instead of leaving it running indefinitely. Deriving the disable list from the current
        // selection (rather than always disabling everything) also means this resource's declared
        // parameters change whenever the selection changes, so CloudFormation actually invokes its
        // onUpdate call instead of treating it as unchanged.
        List<String> deselectedTypes = ALL_RESOURCE_TYPES.stream()
            .filter(type -> !resourceTypes.contains(type))
            .toList();

        AwsCustomResource resetResource = null;
        if (!deselectedTypes.isEmpty()) {
            AwsSdkCall resetCall = AwsSdkCall.builder()
                .service("Inspector2")
                .action("disable")
                .parameters(Map.of(
                    "accountIds", List.of(account),
                    "resourceTypes", deselectedTypes
                ))
                .physicalResourceId(PhysicalResourceId.of(
                    "inspector2-reset-" + account + "-" + region + "-" + String.join("-", deselectedTypes)))
                .region(region)
                .build();

            resetResource = AwsCustomResource.Builder.create(this, "Inspector2Reset")
                .onCreate(resetCall)
                .onUpdate(resetCall)
                .policy(AwsCustomResourcePolicy.fromSdkCalls(
                    SdkCallsPolicyOptions.builder()
                        .resources(List.of("*"))
                        .build()
                ))
                .build();
        }

        AwsSdkCall enableCall = AwsSdkCall.builder()
            .service("Inspector2")
            .action("enable")
            .parameters(Map.of(
                "accountIds", List.of(account),
                "resourceTypes", resourceTypes
            ))
            .physicalResourceId(PhysicalResourceId.of("inspector2-enable-" + account + "-" + region))
            .region(region)
            .build();

        // No onDelete: Inspector2:Disable applies to the whole account and region, not to this
        // stack alone. Deleting this stack must not disable vulnerability scanning for every
        // other stack or operator relying on it in the same account/region.
        AwsCustomResource enableResource = AwsCustomResource.Builder.create(this, "Inspector2Enable")
            .onCreate(enableCall)
            .onUpdate(enableCall)
            .policy(AwsCustomResourcePolicy.fromSdkCalls(
                SdkCallsPolicyOptions.builder()
                    .resources(List.of("*"))
                    .build()
            ))
            .build();

        // inspector2:Enable provisions the AWSServiceRoleForAmazonInspector2 service-linked role
        // on first use in the account; fromSdkCalls only infers the inspector2:Enable/Disable
        // actions, not this IAM side effect, so it needs an explicit grant.
        enableResource.getGrantPrincipal().addToPrincipalPolicy(PolicyStatement.Builder.create()
            .effect(Effect.ALLOW)
            .actions(List.of("iam:CreateServiceLinkedRole"))
            .resources(List.of(
                "arn:aws:iam::" + account + ":role/aws-service-role/inspector2.amazonaws.com/AWSServiceRoleForAmazonInspector2"))
            .conditions(Map.of("StringEquals", Map.of("iam:AWSServiceName", "inspector2.amazonaws.com")))
            .build());

        if (resetResource != null) {
            enableResource.getNode().addDependency(resetResource);
        }

        LOG.info("Inspector enabled: " + resourceTypes);
    }
}