diff --git a/docs/data-sources/cloud_compliance_framework_controls.md b/docs/data-sources/cloud_compliance_framework_controls.md new file mode 100644 index 0000000..bfda9e7 --- /dev/null +++ b/docs/data-sources/cloud_compliance_framework_controls.md @@ -0,0 +1,86 @@ +--- +page_title: "crowdstrike_cloud_compliance_framework_controls Data Source - crowdstrike" +subcategory: "Cloud Compliance" +description: |- + This data source retrieves all or a subset of controls within compliance benchmarks. All non-FQL fields can accept wildcards * and query Falcon using logical AND. If FQL is defined, all other fields will be ignored. For advanced queries to further narrow your search, please use a Falcon Query Language (FQL) filter. For additional information on FQL filtering and usage, refer to the official CrowdStrike documentation: Falcon Query Language (FQL) https://falcon.crowdstrike.com/documentation/page/d3c84a1b/falcon-query-language-fql + API Scopes + The following API scopes are required: + Cloud Security Policies | Read +--- + +# crowdstrike_cloud_compliance_framework_controls (Data Source) + +This data source retrieves all or a subset of controls within compliance benchmarks. All non-FQL fields can accept wildcards `*` and query Falcon using logical AND. If FQL is defined, all other fields will be ignored. For advanced queries to further narrow your search, please use a Falcon Query Language (FQL) filter. For additional information on FQL filtering and usage, refer to the official CrowdStrike documentation: [Falcon Query Language (FQL)](https://falcon.crowdstrike.com/documentation/page/d3c84a1b/falcon-query-language-fql) + +## API Scopes + +The following API scopes are required: + +- Cloud Security Policies | Read + + +## Example Usage + +```terraform +terraform { + required_providers { + crowdstrike = { + source = "registry.terraform.io/crowdstrike/crowdstrike" + } + } +} + +provider "crowdstrike" { + cloud = "us-2" +} + +# retrieve all controls under a named benchmark +data "crowdstrike_cloud_compliance_framework_controls" "all" { + benchmark = "CIS 1.0.0 AWS Web Architecture" +} + +# retrieve a single control within a benchmark by name +data "crowdstrike_cloud_compliance_framework_controls" "by_name" { + name = "Ensure subnets for the Web tier are created" + benchmark = "CIS 1.0.0 AWS Web Architecture" +} + +# retrieve a single control within a benchmark by requirement +data "crowdstrike_cloud_compliance_framework_controls" "by_requirement" { + requirement = "2.1" + benchmark = "CIS 1.0.0 AWS Web Architecture" +} + +# query by FQL filter +data "crowdstrike_cloud_compliance_framework_controls" "fql" { + fql = "compliance_control_name:'Ensure subnets for the Web tier are created'" +} +``` + + +## Schema + +### Optional + +- `benchmark` (String) Name of the compliance benchmark in the framework. Examples: `AWS Foundational Security Best Practices v1.*`, `CIS 1.2.0 GCP`, `CIS 1.8.0 GKE` +- `control_name` (String) Name of the control. Examples: `Ensure security contact phone is set`, `Ensure that Azure Defender*` +- `fql` (String) Falcon Query Language (FQL) filter for advanced control searches. FQL filter, allowed props: `compliance_control_name`, `compliance_control_authority`, `compliance_control_type`, `compliance_control_section`, `compliance_control_requirement`, `compliance_control_benchmark_name`, `compliance_control_benchmark_version` +- `requirement` (String) Requirement of the control(s) within the framework. Examples: `2.*`, `1.1` +- `section` (String) Section of the benchmark where the control(s) reside. Examples: `Data Protection`, `Data*` + +### Read-Only + +- `controls` (Attributes Set) Security framework and compliance rule information. (see [below for nested schema](#nestedatt--controls)) + + +### Nested Schema for `controls` + +Read-Only: + +- `authority` (String) The compliance authority for the framework +- `benchmark` (String) The compliance benchmark within the framework. +- `code` (String) The unique compliance framework rule code. +- `id` (String) The id of the compliance control. +- `name` (String) The name of the control. +- `requirement` (String) The compliance framework requirement. +- `section` (String) The section within the compliance benchmark. diff --git a/docs/data-sources/cloud_posture_rules.md b/docs/data-sources/cloud_posture_rules.md new file mode 100644 index 0000000..50640dd --- /dev/null +++ b/docs/data-sources/cloud_posture_rules.md @@ -0,0 +1,106 @@ +--- +page_title: "crowdstrike_cloud_posture_rules Data Source - crowdstrike" +subcategory: "Cloud Posture" +description: |- + This data source retrieves detailed information about a specific cloud posture rule, including its unique identifier (ID) and associated attributes.All non-FQL fields can accept wildcards * and query Falcon using logical AND. If FQL is defined, all other fields will be ignored. For advanced queries to further narrow your search, please use a Falcon Query Language (FQL) filter. For additional information on FQL filtering and usage, refer to the official CrowdStrike documentation: Falcon Query Language (FQL) https://falcon.crowdstrike.com/documentation/page/d3c84a1b/falcon-query-language-fql + API Scopes + The following API scopes are required: + Cloud Security Policies | Read & Write +--- + +# crowdstrike_cloud_posture_rules (Data Source) + +This data source retrieves detailed information about a specific cloud posture rule, including its unique identifier (ID) and associated attributes.All non-FQL fields can accept wildcards `*` and query Falcon using logical AND. If FQL is defined, all other fields will be ignored. For advanced queries to further narrow your search, please use a Falcon Query Language (FQL) filter. For additional information on FQL filtering and usage, refer to the official CrowdStrike documentation: [Falcon Query Language (FQL)](https://falcon.crowdstrike.com/documentation/page/d3c84a1b/falcon-query-language-fql) + +## API Scopes + +The following API scopes are required: + +- Cloud Security Policies | Read & Write + + +## Example Usage + +```terraform +terraform { + required_providers { + crowdstrike = { + source = "registry.terraform.io/crowdstrike/crowdstrike" + } + } +} + +provider "crowdstrike" { + cloud = "us-2" +} + +# return a single rule within a cloud provider +data "crowdstrike_cloud_posture_rules" "specific" { + cloud_provider = "AWS" + rule_name = "NLB/ALB configured publicly with TLS/SSL disabled" +} + +# query by FQL filter +data "crowdstrike_cloud_posture_rules" "original" { + fql = "rule_name:'NLB/ALB configured publicly with TLS/SSL disabled'" +} + +# return all rules for a specific resource type within a benchmark +data "crowdstrike_cloud_posture_rules" "original" { + resource_type = "AWS::ElasticLoadBalancingV2::*" + benchmark = "CIS 1.0.0 AWS Web Architecture" +} + +# return all rules for a specific resource type within an entire framework +data "crowdstrike_cloud_posture_rules" "original" { + resource_type = "AWS::ElasticLoadBalancingV2::*" + framework = "CIS" +} +``` + + +## Schema + +### Optional + +- `benchmark` (String) Name of the benchmark that this rule is attached to. Note that rules can be associated with multiple benchmarks. Example: `CIS 1.0.0 AWS*` +- `cloud_provider` (String) Cloud provider for where the rule resides. +- `fql` (String) Falcon Query Language (FQL) filter for advanced control searches. FQL filter, allowed props: `rule_origin`, `rule_parent_uuid`, `rule_name`, `rule_description`, `rule_domain`, `rule_status`, `rule_severity`, `rule_short_code`, `rule_service`, `rule_resource_type`, `rule_provider`, `rule_subdomain`, `rule_auto_remediable`, `rule_control_requirement`, `rule_control_section`, `rule_compliance_benchmark`, `rule_compliance_framework`, `rule_mitre_tactic`, `rule_mitre_technique`, `rule_created_at`, `rule_updated_at`, `rule_updated_by` +- `framework` (String) Name of the framework that this rule is attached to. Note that rules can be associated with multiple benchmarks. Examples: CIS, NIST +- `resource_type` (String) Name of the resource type to search for. Examples: `AWS::IAM::CredentialReport`, `Microsoft.Compute/virtualMachines`, `container.googleapis.com/Cluster`. +- `rule_name` (String) Name of the rule to search for. If no name is defined all rules in a cloud provider will be returned. +- `service` (String) Name of the service within the cloud provider that rule is for. Examples: IAM, S3, Microsoft.Compute + +### Read-Only + +- `rules` (Attributes Set) List of cloud posture rules (see [below for nested schema](#nestedatt--rules)) + + +### Nested Schema for `rules` + +Read-Only: + +- `alert_info` (List of String) A list of the alert logic and detection criteria for rule violations. +- `attack_types` (Set of String) Specific attack types associated with the rule. +- `auto_remediable` (Boolean) Autoremediation enabled for the policy rule +- `cloud_platform` (String) Cloud platform for the policy rule. +- `cloud_provider` (String) Cloud provider for the policy rule. +- `controls` (Attributes Set) Security framework and compliance rule information. (see [below for nested schema](#nestedatt--rules--controls)) +- `description` (String) Description of the policy rule. +- `domain` (String) Domain for the policy rule. +- `id` (String) Unique identifier of the policy rule. +- `logic` (String) Rego logic for the policy rule. +- `name` (String) Name of the policy rule. +- `parent_rule_id` (String) Id of the parent rule to inherit properties from. +- `remediation_info` (List of String) Information about how to remediate issues detected by this rule. +- `resource_type` (String) The full resource type. Format examples: `AWS::IAM::CredentialReport`, `Microsoft.Compute/virtualMachines`, `container.googleapis.com/Cluster` +- `severity` (String) Severity of the rule. Valid values are `critical`, `high`, `medium`, `informational`. +- `subdomain` (String) Subdomain for the policy rule. Valid values are 'IOM' (Indicators of Misconfiguration) or 'IAC' (Infrastructure as Code). IOM is only supported at this time. + + +### Nested Schema for `rules.controls` + +Required: + +- `authority` (String) The compliance framework +- `code` (String) The compliance framework rule code diff --git a/docs/resources/cloud_posture_custom_rule.md b/docs/resources/cloud_posture_custom_rule.md new file mode 100644 index 0000000..a505dc7 --- /dev/null +++ b/docs/resources/cloud_posture_custom_rule.md @@ -0,0 +1,147 @@ +--- +page_title: "crowdstrike_cloud_posture_custom_rule Resource - crowdstrike" +subcategory: "Cloud Posture" +description: |- + This resource manages custom cloud posture rules. These rules can be created either by inheriting properties from a parent rule with minimal customization, or by fully customizing all attributes for maximum flexibility. To create a rule based on a parent rule, utilize the crowdstrike_cloud_posture_rules data source to gather parent rule information to use in the new custom rule. The crowdstrike_cloud_compliance_framework_controls data source can be used to query Falcon for compliance benchmark controls to associate with custom rules created with this resource. + API Scopes + The following API scopes are required: + Cloud Security Policies | Read & Write +--- + +# crowdstrike_cloud_posture_custom_rule (Resource) + +This resource manages custom cloud posture rules. These rules can be created either by inheriting properties from a parent rule with minimal customization, or by fully customizing all attributes for maximum flexibility. To create a rule based on a parent rule, utilize the `crowdstrike_cloud_posture_rules` data source to gather parent rule information to use in the new custom rule. The `crowdstrike_cloud_compliance_framework_controls` data source can be used to query Falcon for compliance benchmark controls to associate with custom rules created with this resource. + +## API Scopes + +The following API scopes are required: + +- Cloud Security Policies | Read & Write + + +## Example Usage + +```terraform +terraform { + required_providers { + crowdstrike = { + source = "registry.terraform.io/crowdstrike/crowdstrike" + } + } +} + +provider "crowdstrike" { + cloud = "us-2" +} + +# Custom rule derived from a parent rule with specific modifications +resource "crowdstrike_cloud_posture_custom_rule" "copy_rule" { + resource_type = "AWS::EC2::Instance" + name = "Test Terraform" + description = "Test Terraform" + cloud_provider = "AWS" + severity = "informational" + remediation_info = [ + "Remediation step 1", + "Remediation step 2", + "Remediation step 3", + ] + alert_info = [ + "First item in alert info", + "Second item in alert info" + ] + controls = [ + { + authority = "CIS", + code = "89" + }, + { + authority = "CIS", + code = "791" + } + ] + parent_rule_id = "190c2d3d-8b0e-4838-bf11-4c6e044b9cb1" +} + +resource "crowdstrike_cloud_posture_custom_rule" "custom_rule" { + resource_type = "AWS::EC2::Instance" + name = "Test Terraform" + description = "Test Terraform" + cloud_provider = "AWS" + attack_types = [ + "Attack Type 1", + "Attack Type 2" + ] + remediation_info = [ + "Remediation step 1", + "Remediation step 2", + "Remediation step 3", + ] + severity = "medium" + logic = < +## Schema + +### Required + +- `cloud_provider` (String) Cloud provider for the policy rule. +- `description` (String) Description of the policy rule. +- `name` (String) Name of the policy rule. +- `resource_type` (String) The full resource type. Examples: `AWS::IAM::CredentialReport`, `Microsoft.Compute/virtualMachines`, `container.googleapis.com/Cluster` + +### Optional + +- `alert_info` (List of String) A list of the alert logic and detection criteria for rule violations. When `alert_info` is not defined and `parent_rule_id` is defined, this field will inherit the parent rule's `alert_info`. Do not include numbering within this list. The Falcon console will automatically add numbering. +- `attack_types` (Set of String) Specific attack types associated with the rule. Note: If `parent_rule_id` is defined, attack types will be inherited from the parent rule and cannot be specified using this field. +- `controls` (Attributes Set) Security framework and compliance rule information. Utilize the `crowdstrike_cloud_compliance_framework_controls` data source to obtain this information. When `controls` is not defined and `parent_rule_id` is defined, this field will inherit the parent rule's `controls`. (see [below for nested schema](#nestedatt--controls)) +- `logic` (String) Rego logic for the rule. If this is not defined, then parent_rule_id must be defined. When `parent_rule_id` is defined, `logic` from the parent rule is not visible, but it is used for triggering this rule. +- `parent_rule_id` (String) Id of the parent rule to inherit properties from. The `crowdstrike_cloud_posture_rules` data source can be used to query Falcon for parent rule information to use in this field. Required if `logic` is not specified. +- `remediation_info` (List of String) Information about how to remediate issues detected by this rule. Do not include numbering within this list. The Falcon console will automatically add numbering. +- `severity` (String) Severity of the rule. Valid values are `critical`, `high`, `medium`, `informational`. + +### Read-Only + +- `cloud_platform` (String) Cloud platform for the policy rule. +- `domain` (String) CrowdStrike domain for the custom rule. Default is CSPM +- `id` (String) Unique identifier of the policy rule. +- `subdomain` (String) Subdomain for the policy rule. Valid values are 'IOM' (Indicators of Misconfiguration) or 'IAC' (Infrastructure as Code). IOM is only supported at this time. + + +### Nested Schema for `controls` + +Required: + +- `authority` (String) The compliance framework +- `code` (String) The compliance framework rule code + +## Import + +Import is supported using the following syntax: + +```shell +# Cloud Posture Custom Rule resources can be imported using their UUID, e.g. +terraform import crowdstrike_cloud_posture_custom_rule.example 123e4567-e89b-12d3-a456-426614174000 +``` diff --git a/examples/data-sources/crowdstrike_cloud_compliance_framework_controls/data-source.tf b/examples/data-sources/crowdstrike_cloud_compliance_framework_controls/data-source.tf new file mode 100644 index 0000000..18820c2 --- /dev/null +++ b/examples/data-sources/crowdstrike_cloud_compliance_framework_controls/data-source.tf @@ -0,0 +1,33 @@ +terraform { + required_providers { + crowdstrike = { + source = "registry.terraform.io/crowdstrike/crowdstrike" + } + } +} + +provider "crowdstrike" { + cloud = "us-2" +} + +# retrieve all controls under a named benchmark +data "crowdstrike_cloud_compliance_framework_controls" "all" { + benchmark = "CIS 1.0.0 AWS Web Architecture" +} + +# retrieve a single control within a benchmark by name +data "crowdstrike_cloud_compliance_framework_controls" "by_name" { + name = "Ensure subnets for the Web tier are created" + benchmark = "CIS 1.0.0 AWS Web Architecture" +} + +# retrieve a single control within a benchmark by requirement +data "crowdstrike_cloud_compliance_framework_controls" "by_requirement" { + requirement = "2.1" + benchmark = "CIS 1.0.0 AWS Web Architecture" +} + +# query by FQL filter +data "crowdstrike_cloud_compliance_framework_controls" "fql" { + fql = "compliance_control_name:'Ensure subnets for the Web tier are created'" +} diff --git a/examples/data-sources/crowdstrike_cloud_posture_rules/data-source.tf b/examples/data-sources/crowdstrike_cloud_posture_rules/data-source.tf new file mode 100644 index 0000000..3507a1f --- /dev/null +++ b/examples/data-sources/crowdstrike_cloud_posture_rules/data-source.tf @@ -0,0 +1,34 @@ +terraform { + required_providers { + crowdstrike = { + source = "registry.terraform.io/crowdstrike/crowdstrike" + } + } +} + +provider "crowdstrike" { + cloud = "us-2" +} + +# return a single rule within a cloud provider +data "crowdstrike_cloud_posture_rules" "specific" { + cloud_provider = "AWS" + rule_name = "NLB/ALB configured publicly with TLS/SSL disabled" +} + +# query by FQL filter +data "crowdstrike_cloud_posture_rules" "original" { + fql = "rule_name:'NLB/ALB configured publicly with TLS/SSL disabled'" +} + +# return all rules for a specific resource type within a benchmark +data "crowdstrike_cloud_posture_rules" "original" { + resource_type = "AWS::ElasticLoadBalancingV2::*" + benchmark = "CIS 1.0.0 AWS Web Architecture" +} + +# return all rules for a specific resource type within an entire framework +data "crowdstrike_cloud_posture_rules" "original" { + resource_type = "AWS::ElasticLoadBalancingV2::*" + framework = "CIS" +} diff --git a/examples/resources/crowdstrike_cloud_posture_custom_rule/import.sh b/examples/resources/crowdstrike_cloud_posture_custom_rule/import.sh new file mode 100755 index 0000000..88d94ed --- /dev/null +++ b/examples/resources/crowdstrike_cloud_posture_custom_rule/import.sh @@ -0,0 +1,2 @@ +# Cloud Posture Custom Rule resources can be imported using their UUID, e.g. +terraform import crowdstrike_cloud_posture_custom_rule.example 123e4567-e89b-12d3-a456-426614174000 diff --git a/examples/resources/crowdstrike_cloud_posture_custom_rule/resource.tf b/examples/resources/crowdstrike_cloud_posture_custom_rule/resource.tf new file mode 100644 index 0000000..adf2285 --- /dev/null +++ b/examples/resources/crowdstrike_cloud_posture_custom_rule/resource.tf @@ -0,0 +1,78 @@ +terraform { + required_providers { + crowdstrike = { + source = "registry.terraform.io/crowdstrike/crowdstrike" + } + } +} + +provider "crowdstrike" { + cloud = "us-2" +} + +# Custom rule derived from a parent rule with specific modifications +resource "crowdstrike_cloud_posture_custom_rule" "copy_rule" { + resource_type = "AWS::EC2::Instance" + name = "Test Terraform" + description = "Test Terraform" + cloud_provider = "AWS" + severity = "informational" + remediation_info = [ + "Remediation step 1", + "Remediation step 2", + "Remediation step 3", + ] + alert_info = [ + "First item in alert info", + "Second item in alert info" + ] + controls = [ + { + authority = "CIS", + code = "89" + }, + { + authority = "CIS", + code = "791" + } + ] + parent_rule_id = "190c2d3d-8b0e-4838-bf11-4c6e044b9cb1" +} + +resource "crowdstrike_cloud_posture_custom_rule" "custom_rule" { + resource_type = "AWS::EC2::Instance" + name = "Test Terraform" + description = "Test Terraform" + cloud_provider = "AWS" + attack_types = [ + "Attack Type 1", + "Attack Type 2" + ] + remediation_info = [ + "Remediation step 1", + "Remediation step 2", + "Remediation step 3", + ] + severity = "medium" + logic = < 0 { + filter = strings.Join(filters, "+") + } + + if filter != "" { + params.Filter = &filter + } + } else { + params.Filter = &fql + } + + for { + resp, err := r.client.CloudPolicies.QueryComplianceControls(¶ms) + if err != nil { + if badRequest, ok := err.(*cloud_policies.QueryComplianceControlsBadRequest); ok { + diags.AddError( + "Error Retrieving Control IDs", + fmt.Sprintf("Failed to retrieve controls (400): %+v", *badRequest.Payload.Errors[0].Message), + ) + return controls, diags + } + + if internalServerError, ok := err.(*cloud_policies.QueryComplianceControlsInternalServerError); ok { + diags.AddError( + "Error Retrieving Control IDs", + fmt.Sprintf("Failed to retrieve controls (500): %+v", *internalServerError.Payload.Errors[0].Message), + ) + return controls, diags + } + + diags.AddError( + "Error Retrieving Control IDs", + fmt.Sprintf("Failed to retrieve controls: %+v", err), + ) + + return controls, diags + } + + if resp == nil || resp.Payload == nil || len(resp.Payload.Resources) == 0 { + return controls, diags + } + + payload := resp.GetPayload() + + if err = falcon.AssertNoError(payload.Errors); err != nil { + diags.AddError( + "Error Retrieving Control IDs", + fmt.Sprintf("Failed to retrieve control IDs: %s", err.Error()), + ) + return controls, diags + } + + if len(payload.Resources) < 1 { + return controls, diags + } + + controlsInfo, diags := r.describeControls(ctx, payload.Resources) + if diags.HasError() { + return controls, diags + } + + for _, control := range controlsInfo { + var benchmark types.String + if control.SecurityFramework != nil { + benchmark = types.StringPointerValue(control.SecurityFramework[0].Name) + } + controls = append(controls, cloudComplianceFrameworkControlModel{ + Authority: types.StringPointerValue(control.Authority), + Code: types.StringPointerValue(control.Code), + Requirement: types.StringValue(control.Requirement), + Benchmark: benchmark, + Name: types.StringPointerValue(control.Name), + Section: types.StringValue(control.SectionName), + Id: types.StringPointerValue(control.UUID), + }) + } + + if payload.Meta != nil && payload.Meta.Pagination != nil { + pagination := payload.Meta.Pagination + if pagination.Offset != nil && pagination.Total != nil && *pagination.Offset >= int32(*pagination.Total) { + tflog.Info(ctx, "Pagination complete", map[string]any{"meta": payload.Meta}) + break + } + } + + offset += limit + } + + return controls, diags +} + +func (r *cloudComplianceFrameworkControlDataSource) describeControls(ctx context.Context, ids []string) ([]*models.ApimodelsControl, diag.Diagnostics) { + var diags diag.Diagnostics + var controls []*models.ApimodelsControl + params := cloud_policies.GetComplianceControlsParams{ + Context: ctx, + Ids: ids, + } + + resp, err := r.client.CloudPolicies.GetComplianceControls(¶ms) + if err != nil { + if badRequest, ok := err.(*cloud_policies.GetComplianceControlsBadRequest); ok { + diags.AddError( + "Error Retrieving Compliance Control Information", + fmt.Sprintf("Failed to retrieve compliance control information (400): %+v", *badRequest.Payload.Errors[0].Message), + ) + return nil, diags + } + + if notFound, ok := err.(*cloud_policies.GetComplianceControlsNotFound); ok { + diags.AddError( + "Error Retrieving Compliance Control Information", + fmt.Sprintf("Failed to retrieve compliance control information (404): %+v", *notFound.Payload.Errors[0].Message), + ) + return nil, diags + } + + if internalServerError, ok := err.(*cloud_policies.GetComplianceControlsInternalServerError); ok { + diags.AddError( + "Error Retrieving Compliance Control Information", + fmt.Sprintf("Failed to retrieve compliance control information (500): %+v", *internalServerError.Payload.Errors[0].Message), + ) + return nil, diags + } + + diags.AddError( + "Error Retrieving Compliance Control Information", + fmt.Sprintf("Failed to retrieve compliance control information: %+v", err), + ) + + return nil, diags + } + + if resp == nil || resp.Payload == nil || len(resp.Payload.Resources) == 0 { + diags.AddError( + "Error Retrieving Compliance Control Information", + "Failed to retrieve compliance control information: The API returned an empty payload.", + ) + return nil, diags + } + + payload := resp.GetPayload() + + if err = falcon.AssertNoError(payload.Errors); err != nil { + diags.AddError( + "Error Retrieving Compliance Control Information", + fmt.Sprintf("Failed to retrieve compliance controls: %s", err.Error()), + ) + return nil, diags + } + + controls = payload.Resources + + return controls, diags +} diff --git a/internal/cloud_compliance/framework_control_data_source_test.go b/internal/cloud_compliance/framework_control_data_source_test.go new file mode 100644 index 0000000..18fa532 --- /dev/null +++ b/internal/cloud_compliance/framework_control_data_source_test.go @@ -0,0 +1,96 @@ +package cloudcompliance_test + +import ( + "fmt" + "testing" + + "github.com/crowdstrike/terraform-provider-crowdstrike/internal/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestCloudComplianceFrameworkControlDataSource(t *testing.T) { + controlName := `Ensure CloudFront to Origin connection is configured using TLS1.1+ as the SSL\\TLS protocol` + controlNameResponse := "Ensure CloudFront to Origin connection is configured using TLS1.1+ as the SSL\\TLS protocol" + benchmark := "CIS 1.0.0 AWS Web Architecture" + requirement := "1.17" + resourcePrefix := "data.crowdstrike_cloud_compliance_framework_controls." + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + PreCheck: func() { acctest.PreCheck(t) }, + Steps: []resource.TestStep{ + { + Config: testByFqlConfig( + fmt.Sprintf( + "compliance_control_name:'%s'+"+ + "compliance_control_requirement:'%s'+"+ + "compliance_control_benchmark_name:'%s'", + controlName, + requirement, + benchmark, + ), + "by_fql", + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourcePrefix+"by_fql", "controls.0.benchmark", benchmark), + resource.TestCheckResourceAttr(resourcePrefix+"by_fql", "controls.0.name", controlNameResponse), + resource.TestCheckResourceAttr(resourcePrefix+"by_fql", "controls.0.requirement", requirement), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_fql", "controls.0.section"), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_fql", "controls.0.id"), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_fql", "controls.0.authority"), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_fql", "controls.0.code"), + ), + }, + { + Config: testByNameConfig(controlName, benchmark, "by_name"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourcePrefix+"by_name", "controls.0.benchmark", benchmark), + resource.TestCheckResourceAttr(resourcePrefix+"by_name", "controls.0.name", controlNameResponse), + resource.TestCheckResourceAttr(resourcePrefix+"by_name", "controls.0.requirement", requirement), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_name", "controls.0.section"), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_name", "controls.0.id"), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_name", "controls.0.authority"), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_name", "controls.0.code"), + ), + }, + { + Config: testByRequirementConfig(requirement, benchmark, "by_requirement"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourcePrefix+"by_requirement", "controls.0.benchmark", benchmark), + resource.TestCheckResourceAttr(resourcePrefix+"by_requirement", "controls.0.name", controlNameResponse), + resource.TestCheckResourceAttr(resourcePrefix+"by_requirement", "controls.0.requirement", requirement), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_requirement", "controls.0.section"), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_requirement", "controls.0.id"), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_requirement", "controls.0.authority"), + resource.TestCheckResourceAttrSet(resourcePrefix+"by_requirement", "controls.0.code"), + ), + }, + }, + }) +} + +func testByFqlConfig(fql string, resourceName string) string { + return fmt.Sprintf(` +data "crowdstrike_cloud_compliance_framework_controls" "%s" { + fql = "%s" +} +`, resourceName, fql) +} + +func testByNameConfig(name string, benchmark string, resourceName string) string { + return fmt.Sprintf(` +data "crowdstrike_cloud_compliance_framework_controls" "%s" { + control_name = "%s" + benchmark = "%s" +} +`, resourceName, name, benchmark) +} + +func testByRequirementConfig(requirement string, benchmark string, resourceName string) string { + return fmt.Sprintf(` +data "crowdstrike_cloud_compliance_framework_controls" "%s" { + requirement = "%s" + benchmark = "%s" +} +`, resourceName, requirement, benchmark) +} diff --git a/internal/cloud_compliance/scopes.go b/internal/cloud_compliance/scopes.go new file mode 100644 index 0000000..c589276 --- /dev/null +++ b/internal/cloud_compliance/scopes.go @@ -0,0 +1,11 @@ +package cloudcompliance + +import "github.com/crowdstrike/terraform-provider-crowdstrike/internal/scopes" + +var cloudComplianceFrameworkScopes = []scopes.Scope{ + { + Name: "Cloud Security Policies", + Read: true, + Write: false, + }, +} diff --git a/internal/cloud_posture/controls.go b/internal/cloud_posture/controls.go new file mode 100644 index 0000000..94de359 --- /dev/null +++ b/internal/cloud_posture/controls.go @@ -0,0 +1,18 @@ +package cloudposture + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type policyControl struct { + Authority types.String `tfsdk:"authority"` + Code types.String `tfsdk:"code"` +} + +func (p policyControl) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "authority": types.StringType, + "code": types.StringType, + } +} diff --git a/internal/cloud_posture/custom_rule_resource.go b/internal/cloud_posture/custom_rule_resource.go new file mode 100644 index 0000000..1c59947 --- /dev/null +++ b/internal/cloud_posture/custom_rule_resource.go @@ -0,0 +1,877 @@ +package cloudposture + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/crowdstrike/gofalcon/falcon" + "github.com/crowdstrike/gofalcon/falcon/client" + "github.com/crowdstrike/gofalcon/falcon/client/cloud_policies" + "github.com/crowdstrike/gofalcon/falcon/models" + "github.com/crowdstrike/terraform-provider-crowdstrike/internal/scopes" + "github.com/crowdstrike/terraform-provider-crowdstrike/internal/utils" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +var ( + _ resource.Resource = &cloudPostureCustomRuleResource{} + _ resource.ResourceWithConfigure = &cloudPostureCustomRuleResource{} + _ resource.ResourceWithImportState = &cloudPostureCustomRuleResource{} +) + +var ( + documentationSection string = "Cloud Posture" + resourceMarkdownDescription string = "This resource manages custom cloud posture rules. " + + "These rules can be created either by inheriting properties from a parent rule with minimal customization, or by fully customizing all attributes for maximum flexibility. " + + "To create a rule based on a parent rule, utilize the `crowdstrike_cloud_posture_rules` data source to gather parent rule information to use in the new custom rule. " + + "The `crowdstrike_cloud_compliance_framework_controls` data source can be used to query Falcon for compliance benchmark controls to associate with custom rules created with this resource. " + requiredScopes []scopes.Scope = cloudPostureRuleScopes +) + +func NewCloudPostureCustomRuleResource() resource.Resource { + return &cloudPostureCustomRuleResource{} +} + +type cloudPostureCustomRuleResource struct { + client *client.CrowdStrikeAPISpecification +} + +type cloudPostureCustomRuleResourceModel struct { + ID types.String `tfsdk:"id"` + AlertInfo types.List `tfsdk:"alert_info"` + Controls types.Set `tfsdk:"controls"` + Description types.String `tfsdk:"description"` + Domain types.String `tfsdk:"domain"` + Logic types.String `tfsdk:"logic"` + Name types.String `tfsdk:"name"` + AttackTypes types.Set `tfsdk:"attack_types"` + ParentRuleId types.String `tfsdk:"parent_rule_id"` + CloudPlatform types.String `tfsdk:"cloud_platform"` + CloudProvider types.String `tfsdk:"cloud_provider"` + RemediationInfo types.List `tfsdk:"remediation_info"` + ResourceType types.String `tfsdk:"resource_type"` + Severity types.String `tfsdk:"severity"` + Subdomain types.String `tfsdk:"subdomain"` +} + +func (r *cloudPostureCustomRuleResource) Configure( + ctx context.Context, + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.CrowdStrikeAPISpecification) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf( + "Expected *client.CrowdStrikeAPISpecification, got: %T. Please report this issue to the provider developers.", + req.ProviderData, + ), + ) + + return + } + + r.client = client +} + +func (r *cloudPostureCustomRuleResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_cloud_posture_custom_rule" +} + +func (r *cloudPostureCustomRuleResource) Schema( + _ context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + MarkdownDescription: utils.MarkdownDescription(documentationSection, resourceMarkdownDescription, requiredScopes), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "Unique identifier of the policy rule.", + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`), + "must be a valid Id in the format of 7c86a274-c04b-4292-9f03-dafae42bde97", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "alert_info": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + MarkdownDescription: "A list of the alert logic and detection criteria for rule violations. " + + "When `alert_info` is not defined and `parent_rule_id` is defined, this field will inherit the parent rule's `alert_info`. " + + "Do not include numbering within this list. The Falcon console will automatically add numbering.", + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.LengthAtLeast(1), + ), + }, + }, + "controls": schema.SetNestedAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Security framework and compliance rule information. " + + "Utilize the `crowdstrike_cloud_compliance_framework_controls` data source to obtain this information. " + + "When `controls` is not defined and `parent_rule_id` is defined, this field will inherit the parent rule's `controls`.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "authority": schema.StringAttribute{ + Required: true, + Description: "The compliance framework", + }, + "code": schema.StringAttribute{ + Required: true, + Description: "The compliance framework rule code", + }, + }, + }, + Default: setdefault.StaticValue(types.SetNull(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "authority": types.StringType, + "code": types.StringType, + }, + })), + }, + "description": schema.StringAttribute{ + Required: true, + Description: "Description of the policy rule.", + }, + "domain": schema.StringAttribute{ + Computed: true, + Default: stringdefault.StaticString("CSPM"), + Description: "CrowdStrike domain for the custom rule. Default is CSPM", + }, + "attack_types": schema.SetAttribute{ + Optional: true, + Computed: true, + MarkdownDescription: "Specific attack types associated with the rule. " + + "Note: If `parent_rule_id` is defined, attack types will be inherited from the parent rule and cannot be specified using this field.", + ElementType: types.StringType, + }, + "logic": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Rego logic for the rule. " + + "If this is not defined, then parent_rule_id must be defined. " + + "When `parent_rule_id` is defined, `logic` from the parent rule is not visible, but it is used for triggering this rule.", + }, + "name": schema.StringAttribute{ + Required: true, + Description: "Name of the policy rule.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "parent_rule_id": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Id of the parent rule to inherit properties from. " + + "The `crowdstrike_cloud_posture_rules` data source can be used to query Falcon for parent rule information to use in this field. " + + "Required if `logic` is not specified.", + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`), + "must be a valid Id in the format of 7c86a274-c04b-4292-9f03-dafae42bde97", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "cloud_platform": schema.StringAttribute{ + Computed: true, + Description: "Cloud platform for the policy rule.", + }, + "cloud_provider": schema.StringAttribute{ + Required: true, + Description: "Cloud provider for the policy rule.", + Validators: []validator.String{ + stringvalidator.OneOf( + "AWS", + "Azure", + "GCP", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "remediation_info": schema.ListAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: "Information about how to remediate issues detected by this rule. " + + "Do not include numbering within this list. The Falcon console will automatically add numbering.", + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.LengthAtLeast(1), + ), + }, + }, + "resource_type": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The full resource type. Examples: " + + "`AWS::IAM::CredentialReport`, " + + "`Microsoft.Compute/virtualMachines`, " + + "`container.googleapis.com/Cluster`", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "severity": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("critical"), + MarkdownDescription: "Severity of the rule. Valid values are `critical`, `high`, `medium`, `informational`.", + Validators: []validator.String{ + stringvalidator.OneOf("critical", "high", "medium", "informational"), + }, + }, + "subdomain": schema.StringAttribute{ + Computed: true, + Description: "Subdomain for the policy rule. Valid values are 'IOM' (Indicators of Misconfiguration) or 'IAC' (Infrastructure as Code). IOM is only supported at this time.", + Default: stringdefault.StaticString("IOM"), + }, + }, + } +} + +func (r *cloudPostureCustomRuleResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var plan cloudPostureCustomRuleResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + // Match Cloud Platform and Cloud Provider until IAC is implemented + if !utils.IsKnown(plan.CloudPlatform) { + plan.CloudPlatform = plan.CloudProvider + } + + rule, diags := r.createCloudPolicyRule(ctx, &plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + // Update state before continuing because we already created the Policy, but + // other operations may fail resulting in created, but not tracked resources. + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), plan.ID)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(plan.wrap(ctx, rule)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *cloudPostureCustomRuleResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var state cloudPostureCustomRuleResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + rule, diags := r.getCloudPolicyRule(ctx, state.ID.ValueString()) + if diags.HasError() { + for _, diag := range diags { + if strings.Contains(diag.Detail(), "resource doesn't exist") { + resp.State.RemoveResource(ctx) + resp.Diagnostics.AddWarning( + "Resource Not Found", + fmt.Sprintf("The resource with ID %s no longer exists in Falcon and will be removed from the Terraform state.", state.ID.ValueString()), + ) + return + } + } + resp.Diagnostics.Append(diags...) + return + } + + if rule == nil { + return + } + + resp.Diagnostics.Append(state.wrap(ctx, rule)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *cloudPostureCustomRuleResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var plan cloudPostureCustomRuleResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if !utils.IsKnown(plan.CloudPlatform) { + plan.CloudPlatform = plan.CloudProvider + } + + rule, diags := r.updateCloudPolicyRule(ctx, &plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + resp.Diagnostics.Append(plan.wrap(ctx, rule)...) + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *cloudPostureCustomRuleResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var state cloudPostureCustomRuleResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.deleteCloudPolicyRule(ctx, state.ID.ValueString())...) +} + +func (r *cloudPostureCustomRuleResource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r cloudPostureCustomRuleResource) ConfigValidators(ctx context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + resourcevalidator.ExactlyOneOf( + path.MatchRoot("logic"), + path.MatchRoot("parent_rule_id"), + ), + resourcevalidator.Conflicting( + path.MatchRoot("parent_rule_id"), + path.MatchRoot("attack_types"), + ), + } +} + +func (m *cloudPostureCustomRuleResourceModel) wrap( + ctx context.Context, + rule *models.ApimodelsRule, +) diag.Diagnostics { + var diags diag.Diagnostics + + m.ID = types.StringPointerValue(rule.UUID) + m.Name = types.StringPointerValue(rule.Name) + m.Description = types.StringPointerValue(rule.Description) + m.Domain = types.StringPointerValue(rule.Domain) + m.Subdomain = types.StringPointerValue(rule.Subdomain) + m.CloudProvider = types.StringPointerValue(rule.Provider) + m.RemediationInfo = convertAlertRemediationInfoToTerraformState(rule.Remediation) + + m.AlertInfo = convertAlertRemediationInfoToTerraformState(rule.AlertInfo) + if diags.HasError() { + return diags + } + + if rule.Severity != nil { + m.Severity = types.StringValue(int32ToSeverity[int32(*rule.Severity)]) + } + + if !m.ParentRuleId.IsNull() { + m.ParentRuleId = types.StringValue(rule.ParentRuleShortUUID) + } else { + m.Logic = types.StringValue(rule.Logic) + } + + m.AttackTypes = types.SetValueMust(types.StringType, []attr.Value{}) + for _, attackType := range rule.AttackTypes { + m.AttackTypes, diags = types.SetValue(types.StringType, append(m.AttackTypes.Elements(), types.StringValue(attackType))) + if diags.HasError() { + return diags + } + } + + var policyControls []policyControl + for _, control := range rule.Controls { + policyControls = append(policyControls, policyControl{ + Authority: types.StringPointerValue(control.Authority), + Code: types.StringPointerValue(control.Code), + }) + } + + m.Controls, diags = types.SetValueFrom( + ctx, + types.ObjectType{AttrTypes: policyControl{}.AttributeTypes()}, + policyControls, + ) + + if diags.HasError() { + return diags + } + + if rule.RuleLogicList != nil { + m.CloudPlatform = types.StringPointerValue(rule.RuleLogicList[0].Platform) + } + + if rule.ResourceTypes != nil { + m.ResourceType = types.StringPointerValue(rule.ResourceTypes[0].ResourceType) + } + return diags +} + +func (r *cloudPostureCustomRuleResource) createCloudPolicyRule(ctx context.Context, plan *cloudPostureCustomRuleResourceModel) (*models.ApimodelsRule, diag.Diagnostics) { + var diags diag.Diagnostics + + body := &models.CommonCreateRuleRequest{ + Description: plan.Description.ValueStringPointer(), + Name: plan.Name.ValueStringPointer(), + Platform: plan.CloudPlatform.ValueStringPointer(), + Provider: plan.CloudProvider.ValueStringPointer(), + ResourceType: plan.ResourceType.ValueStringPointer(), + Domain: plan.Domain.ValueStringPointer(), + Subdomain: plan.Subdomain.ValueStringPointer(), + } + + if plan.ParentRuleId.IsNull() { + + body.Logic = plan.Logic.ValueStringPointer() + body.AlertInfo, diags = convertAlertInfoToAPIFormat(ctx, plan.AlertInfo, false) + if diags.HasError() { + return nil, diags + } + + body.RemediationInfo, diags = convertRemediationInfoToAPIFormat(ctx, plan.RemediationInfo, false) + if diags.HasError() { + return nil, diags + } + + } else { + body.ParentRuleID = plan.ParentRuleId.ValueStringPointer() + body.AlertInfo, diags = convertAlertInfoToAPIFormat(ctx, plan.AlertInfo, true) + if diags.HasError() { + return nil, diags + } + + body.RemediationInfo, diags = convertRemediationInfoToAPIFormat(ctx, plan.RemediationInfo, true) + if diags.HasError() { + return nil, diags + } + } + + var controls []policyControl + body.Controls = []*models.DbmodelsControlReference{} + diags = plan.Controls.ElementsAs(ctx, &controls, false) + if diags.HasError() { + return nil, diags + } + for _, control := range controls { + body.Controls = append(body.Controls, &models.DbmodelsControlReference{ + Authority: control.Authority.ValueStringPointer(), + Code: control.Code.ValueStringPointer(), + }) + } + + severity := severityToInt32[plan.Severity.ValueString()] + body.Severity = &severity + + attackTypes := make([]string, 0, len(plan.AttackTypes.Elements())) + for _, elem := range plan.AttackTypes.Elements() { + if str, ok := elem.(types.String); ok { + attackTypes = append(attackTypes, str.ValueString()) + } + } + + body.AttackTypes = utils.Addr(strings.Join(attackTypes, ",")) + + params := cloud_policies.CreateRuleParams{ + Context: ctx, + Body: body, + } + + resp, err := r.client.CloudPolicies.CreateRule(¶ms) + if err != nil { + if badRequest, ok := err.(*cloud_policies.CreateRuleBadRequest); ok { + diags.AddError( + "Error Creating Rule", + fmt.Sprintf("Failed to create rule (400): %+v", *badRequest.Payload.Errors[0].Message), + ) + return nil, diags + } + + if ruleConflict, ok := err.(*cloud_policies.CreateRuleConflict); ok { + diags.AddError( + "Error Creating Rule", + fmt.Sprintf("Failed to create rule (409): %+v", *ruleConflict.Payload.Errors[0].Message), + ) + return nil, diags + } + + if internalServerError, ok := err.(*cloud_policies.CreateRuleInternalServerError); ok { + diags.AddError( + "Error Creating Rule", + fmt.Sprintf("Failed to create rule (500): %+v", *internalServerError.Payload.Errors[0].Message), + ) + return nil, diags + } + + diags.AddError( + "Error Creating Rule", + fmt.Sprintf("Failed to create rule %s: %+v", plan.Name.ValueString(), err), + ) + + return nil, diags + } + + if resp == nil || resp.Payload == nil || len(resp.Payload.Resources) == 0 { + diags.AddError( + "Error Creating Rule", + "Failed to create rule: Payload is empty.", + ) + return nil, diags + } + + payload := resp.GetPayload() + + if err = falcon.AssertNoError(payload.Errors); err != nil { + diags.AddError( + "Error Creating Rule. Body Error", + fmt.Sprintf("Failed to create rule: %s", err.Error()), + ) + return nil, diags + } + + return payload.Resources[0], diags +} + +func (r *cloudPostureCustomRuleResource) getCloudPolicyRule(ctx context.Context, id string) (*models.ApimodelsRule, diag.Diagnostics) { + var diags diag.Diagnostics + + params := cloud_policies.GetRuleParams{ + Context: ctx, + Ids: []string{id}, + } + + resp, err := r.client.CloudPolicies.GetRule(¶ms) + if err != nil { + if notFound, ok := err.(*cloud_policies.GetRuleNotFound); ok { + diags.AddError( + "Error Retrieving Rule", + fmt.Sprintf("Failed to retrieve rule (404): %s, %+v", id, *notFound.Payload.Errors[0].Message), + ) + return nil, diags + } + + if internalServerError, ok := err.(*cloud_policies.GetRuleInternalServerError); ok { + diags.AddError( + "Error Retrieving Rule", + fmt.Sprintf("Failed to retrieve rule (500): %s, %+v", id, *internalServerError.Payload.Errors[0].Message), + ) + return nil, diags + } + + diags.AddError( + "Error Retrieving Rule", + fmt.Sprintf("Failed to retrieve rule %s: %+v", id, err), + ) + + return nil, diags + } + + if resp == nil || resp.Payload == nil || len(resp.Payload.Resources) == 0 { + return nil, diags + } + + payload := resp.GetPayload() + + if err = falcon.AssertNoError(payload.Errors); err != nil { + diags.AddError( + "Error Retrieving Rule", + fmt.Sprintf("Failed to retrieve rule: %s", err.Error()), + ) + return nil, diags + } + + return payload.Resources[0], diags +} + +func (r *cloudPostureCustomRuleResource) updateCloudPolicyRule(ctx context.Context, plan *cloudPostureCustomRuleResourceModel) (*models.ApimodelsRule, diag.Diagnostics) { + var diags diag.Diagnostics + + body := &models.CommonUpdateRuleRequest{ + Description: plan.Description.ValueString(), + Name: plan.Name.ValueString(), + UUID: plan.ID.ValueStringPointer(), + } + + severity := severityToInt64[plan.Severity.ValueString()] + body.Severity = severity + + attackTypes := make([]string, 0, len(plan.AttackTypes.Elements())) + for _, elem := range plan.AttackTypes.Elements() { + if str, ok := elem.(types.String); ok { + attackTypes = append(attackTypes, str.ValueString()) + } + } + body.AttackTypes = attackTypes + + if !plan.Controls.IsNull() { + var controls []policyControl + body.Controls = []*models.ApimodelsControlReference{} + plan.Controls.ElementsAs(ctx, &controls, false) + for _, control := range controls { + body.Controls = append(body.Controls, &models.ApimodelsControlReference{ + Authority: control.Authority.ValueStringPointer(), + Code: control.Code.ValueStringPointer(), + }) + } + } + + body.RuleLogicList = []*models.ApimodelsRuleLogic{ + { + Platform: plan.CloudPlatform.ValueStringPointer(), + }, + } + + var remediationInfo, alertInfo *string + if plan.ParentRuleId.IsNull() { + alertInfo, diags = convertAlertInfoToAPIFormat(ctx, plan.AlertInfo, false) + if diags.HasError() { + return nil, diags + } + + remediationInfo, diags = convertRemediationInfoToAPIFormat(ctx, plan.RemediationInfo, false) + if diags.HasError() { + return nil, diags + } + + body.RuleLogicList[0].Logic = plan.Logic.ValueString() + } else { + alertInfo, diags = convertAlertInfoToAPIFormat(ctx, plan.AlertInfo, true) + if diags.HasError() { + return nil, diags + } + + remediationInfo, diags = convertRemediationInfoToAPIFormat(ctx, plan.RemediationInfo, true) + if diags.HasError() { + return nil, diags + } + } + + if remediationInfo != nil { + body.RuleLogicList[0].RemediationInfo = remediationInfo + } + + if alertInfo != nil { + body.AlertInfo = *alertInfo + } + + params := cloud_policies.UpdateRuleParams{ + Context: ctx, + Body: body, + } + + resp, err := r.client.CloudPolicies.UpdateRule(¶ms) + if err != nil { + if badRequest, ok := err.(*cloud_policies.UpdateRuleBadRequest); ok { + diags.AddError( + "Error Updating Rule", + fmt.Sprintf("Failed to update rule (400): %+v", *badRequest.Payload.Errors[0].Message), + ) + return nil, diags + } + + if ruleConflict, ok := err.(*cloud_policies.UpdateRuleConflict); ok { + diags.AddError( + "Error Updating Rule", + fmt.Sprintf("Failed to update rule (409): %+v", *ruleConflict.Payload.Errors[0].Message), + ) + return nil, diags + } + + if internalServerError, ok := err.(*cloud_policies.UpdateRuleInternalServerError); ok { + diags.AddError( + "Error Updating Rule", + fmt.Sprintf("Failed to update rule (500): %+v", *internalServerError.Payload.Errors[0].Message), + ) + return nil, diags + } + + diags.AddError( + "Error Updating Rule", + fmt.Sprintf("Failed to update rule: %s", err), + ) + return nil, diags + } + + if resp == nil || resp.Payload == nil || len(resp.Payload.Resources) == 0 { + diags.AddError( + "Error Updating Rule", + "Failed to update rule: Payload is empty.", + ) + return nil, diags + } + + payload := resp.GetPayload() + + if err = falcon.AssertNoError(payload.Errors); err != nil { + diags.AddError( + "Error Updating Rule", + fmt.Sprintf("Failed to update rule: %s", err.Error()), + ) + return nil, diags + } + + return payload.Resources[0], diags +} + +func (r *cloudPostureCustomRuleResource) deleteCloudPolicyRule(ctx context.Context, id string) diag.Diagnostics { + var diags diag.Diagnostics + + params := cloud_policies.DeleteRuleParams{ + Context: ctx, + Ids: []string{id}, + } + + resp, err := r.client.CloudPolicies.DeleteRule(¶ms) + if err != nil { + if internalServerError, ok := err.(*cloud_policies.DeleteRuleInternalServerError); ok { + diags.AddError( + "Error Deleting Rule", + fmt.Sprintf("Failed to delete rule (500) %s: %+v", id, *internalServerError.Payload.Errors[0].Message), + ) + return diags + } + + diags.AddError( + "Error Deleting Rule", + fmt.Sprintf("Failed to delete rule: %s", err), + ) + return diags + } + + if resp == nil || resp.Payload == nil || len(resp.Payload.Resources) == 0 { + diags.AddError( + "Error Deleting Rule", + "Failed to delete rule: The API returned an empty payload. The rule may have already been deleted and will be removed from the Terraform state during the next run.", + ) + return diags + } + + payload := resp.GetPayload() + + if err = falcon.AssertNoError(payload.Errors); err != nil { + diags.AddError( + "Error Deleting Rule", + fmt.Sprintf("Failed to delete rule: %s", err.Error()), + ) + return diags + } + + return diags +} + +func convertAlertInfoToAPIFormat(ctx context.Context, alertInfo basetypes.ListValue, includeNumbering bool) (*string, diag.Diagnostics) { + var diags diag.Diagnostics + var alertInfoStrings []string + var convertedAlertInfo string + + if alertInfo.IsNull() || alertInfo.IsUnknown() { + return nil, diags + } + + // Duplicate rules require the numbering with |\n delimiters + // for Alert Info while custom rules only require | without + // newlines + if includeNumbering { + for i, elem := range alertInfo.Elements() { + str, ok := elem.(types.String) + if !ok { + diags.AddError( + "Error converting AlertInfo", + fmt.Sprintf("Failed to convert element %d to string", i), + ) + return nil, diags + } + alertInfoStrings = append(alertInfoStrings, fmt.Sprintf("%d. %s", i+1, str.ValueString())) + } + + convertedAlertInfo = strings.Join(alertInfoStrings, "|\n") + } else { + diags = alertInfo.ElementsAs(ctx, &alertInfoStrings, false) + convertedAlertInfo = strings.Join(alertInfoStrings, "|") + } + return &convertedAlertInfo, diags +} + +func convertRemediationInfoToAPIFormat(ctx context.Context, info basetypes.ListValue, includeNumbering bool) (*string, diag.Diagnostics) { + var diags diag.Diagnostics + var infoStrings []string + var convertedInfo string + + if info.IsNull() || info.IsUnknown() { + return nil, diags + } + + // Duplicate rules require the numbering with |\n delimiters + // for Remediation info while custom rules only require | without + // newlines + if includeNumbering { + for i, elem := range info.Elements() { + str, ok := elem.(types.String) + if !ok { + diags.AddError( + "Error converting RemediationInfo", + fmt.Sprintf("Failed to convert element %d to string", i), + ) + return nil, diags + } + infoStrings = append(infoStrings, fmt.Sprintf("Step %d. %s", i+1, str.ValueString())) + } + convertedInfo = strings.Join(infoStrings, "|\n") + } else { + diags = info.ElementsAs(ctx, &infoStrings, false) + convertedInfo = strings.Join(infoStrings, "|") + } + + return &convertedInfo, diags +} diff --git a/internal/cloud_posture/custom_rule_resource_test.go b/internal/cloud_posture/custom_rule_resource_test.go new file mode 100644 index 0000000..d6205f2 --- /dev/null +++ b/internal/cloud_posture/custom_rule_resource_test.go @@ -0,0 +1,358 @@ +package cloudposture_test + +// Check empty fields. +// Check nil +// Check from defined to empty or nil. In-place updates. + +import ( + "fmt" + "strings" + "testing" + + "github.com/crowdstrike/terraform-provider-crowdstrike/internal/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +type ruleBaseConfig struct { + ruleNamePrefix string + description []string + subdomain string + domain string + severity []string + remediationInfo [][]string + controls []control + logic []string + alertInfo [][]string + attackTypes [][]string +} + +type ruleCustomConfig struct { + ruleBaseConfig + parentId string + cloudProvider string + cloudPlatform string + resourceType string +} + +type control struct { + authority string + code string +} + +var commonConfig = ruleBaseConfig{ + ruleNamePrefix: "Terraform Automated Test ", + description: []string{ + "This is a description", + "This is an updated description", + }, + subdomain: "IOM", + domain: "CSPM", + severity: []string{"critical", "informational"}, + remediationInfo: [][]string{ + {"This is the first step", "This is the second step"}, + {"This is the first step", "This is the second step", "This is the third step."}, + }, + controls: []control{ + { + authority: "CIS", + code: "791", + }, + { + authority: "CIS", + code: "98", + }, + }, + logic: []string{ + "package crowdstrike\ndefault result = \"pass\"\nresult = \"fail\" if {\n input.tags[_] == \"catch-me\"\n }", + "package crowdstrike\ndefault result = \"pass\"\nresult = \"fail\" if {\n input.tags[_] == \"catch-me-again\"\n }", + }, + alertInfo: [][]string{ + { + "List all Auto Scaling Groups in the account.", + "Check if multiple instance types are included in the configuration.", + "Check if multiple availability zones are configured.", + }, + { + "Check if multiple instance types are included in the configuration.", + "List all Auto Scaling Groups in the account.", + "Check if multiple availability zones are configured.", + "Alert when any of the above conditions are met.", + }, + }, + attackTypes: [][]string{ + {"Look it's an attack type"}, + {"Look it's an attack type", "This is a second attack type"}, + }, +} + +var awsCopyConfig = ruleCustomConfig{ + ruleBaseConfig: commonConfig, + parentId: "0473a26b-7f29-43c7-9581-105f8c9c0b7d", + cloudProvider: "AWS", + cloudPlatform: "AWS", + resourceType: "AWS::EC2::Instance", +} + +var azureCopyConfig = ruleCustomConfig{ + ruleBaseConfig: commonConfig, + parentId: "1c9516e9-490b-461c-8644-9239ff3cf0d3", + cloudProvider: "Azure", + cloudPlatform: "Azure", + resourceType: "Microsoft.Compute/virtualMachines", +} + +var gcpCopyConfig = ruleCustomConfig{ + ruleBaseConfig: commonConfig, + parentId: "0260ffa9-eb65-42f4-a02a-7456d280049a", + cloudProvider: "GCP", + cloudPlatform: "GCP", + resourceType: "sqladmin.googleapis.com/Instance", +} + +func TestCloudPostureCustomRuleResource(t *testing.T) { + var steps []resource.TestStep + + steps = append(steps, generateRuleCopyTests(awsCopyConfig, "AWS")...) + steps = append(steps, generateRuleCopyTests(azureCopyConfig, "Azure")...) + steps = append(steps, generateRuleCopyTests(gcpCopyConfig, "GCP")...) + steps = append(steps, generateRuleLogicTests(awsCopyConfig, "AWS_Rego")...) + steps = append(steps, generateRuleLogicTests(azureCopyConfig, "Azure_Rego")...) + steps = append(steps, generateRuleLogicTests(gcpCopyConfig, "GCP_Rego")...) + steps = append(steps, generateMinimalRuleCopyTests(awsCopyConfig, "AWS_Min")...) + steps = append(steps, generateMinimalRuleCopyTests(azureCopyConfig, "Azure_Min")...) + steps = append(steps, generateMinimalRuleCopyTests(gcpCopyConfig, "GCP_Min")...) + steps = append(steps, generateMinimalRuleLogicTests(awsCopyConfig, "AWS_Min_Rego")...) + steps = append(steps, generateMinimalRuleLogicTests(azureCopyConfig, "Azure_Min_Rego")...) + steps = append(steps, generateMinimalRuleLogicTests(gcpCopyConfig, "GCP_Min_Rego")...) + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + PreCheck: func() { acctest.PreCheck(t) }, + Steps: steps, + }) +} + +func generateRuleCopyTests(config ruleCustomConfig, ruleName string) []resource.TestStep { + var steps []resource.TestStep + resourceName := "crowdstrike_cloud_posture_custom_rule.rule" + "_" + ruleName + + for i := range 2 { + alertInfo := strings.Join([]string{ + `"` + strings.Join(config.ruleBaseConfig.alertInfo[i], `","`) + `"`, + }, "") + remediationInfo := strings.Join([]string{ + `"` + strings.Join(config.ruleBaseConfig.remediationInfo[i], `","`) + `"`, + }, "") + newStep := resource.TestStep{ + Config: fmt.Sprintf(` +resource "crowdstrike_cloud_posture_custom_rule" "rule_%s" { + resource_type = "%s" + name = "%s" + description = "%s" + cloud_provider = "%s" + severity = "%s" + remediation_info = [%s] + controls = [ + %s + ] + parent_rule_id = "%s" + alert_info = [%s] +} +`, ruleName, config.resourceType, config.ruleBaseConfig.ruleNamePrefix+ruleName, config.ruleBaseConfig.description[i], + config.cloudProvider, config.severity[i], remediationInfo, + testGenerateControlBlock(config.ruleBaseConfig.controls[i]), config.parentId, alertInfo), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "subdomain", config.ruleBaseConfig.subdomain), + resource.TestCheckResourceAttr(resourceName, "domain", config.ruleBaseConfig.domain), + resource.TestCheckResourceAttr(resourceName, "resource_type", config.resourceType), + resource.TestCheckResourceAttr(resourceName, "name", config.ruleBaseConfig.ruleNamePrefix+ruleName), + resource.TestCheckResourceAttr(resourceName, "description", config.ruleBaseConfig.description[i]), + resource.TestCheckResourceAttr(resourceName, "cloud_platform", config.cloudPlatform), + resource.TestCheckResourceAttr(resourceName, "cloud_provider", config.cloudProvider), + resource.TestCheckResourceAttr(resourceName, "severity", config.severity[i]), + resource.TestCheckResourceAttr(resourceName, "parent_rule_id", config.parentId), + resource.TestCheckResourceAttr(resourceName, "controls.0.authority", config.ruleBaseConfig.controls[i].authority), + resource.TestCheckResourceAttr(resourceName, "controls.0.code", config.ruleBaseConfig.controls[i].code), + resource.TestCheckResourceAttr(resourceName, fmt.Sprintf("alert_info.%d", len(config.ruleBaseConfig.alertInfo[i])-1), config.ruleBaseConfig.alertInfo[i][len(config.ruleBaseConfig.alertInfo[i])-1]), + resource.TestCheckResourceAttr(resourceName, fmt.Sprintf("remediation_info.%d", len(config.ruleBaseConfig.remediationInfo[i])-1), config.ruleBaseConfig.remediationInfo[i][len(config.ruleBaseConfig.remediationInfo[i])-1]), + resource.TestCheckResourceAttrSet(resourceName, "id"), + ), + } + steps = append(steps, newStep) + } + + return steps +} + +func generateRuleLogicTests(config ruleCustomConfig, ruleName string) []resource.TestStep { + var steps []resource.TestStep + resourceName := "crowdstrike_cloud_posture_custom_rule.rule" + "_" + ruleName + + for i := range 2 { + alertInfo := strings.Join([]string{ + `"` + strings.Join(config.ruleBaseConfig.alertInfo[i], `","`) + `"`, + }, "") + remediationInfo := strings.Join([]string{ + `"` + strings.Join(config.ruleBaseConfig.remediationInfo[i], `","`) + `"`, + }, "") + attackTypes := strings.Join([]string{ + `"` + strings.Join(config.ruleBaseConfig.attackTypes[i], `","`) + `"`, + }, "") + resourceStep := resource.TestStep{ + Config: fmt.Sprintf(` +resource "crowdstrike_cloud_posture_custom_rule" "rule_%s" { + resource_type = "%s" + name = "%s" + description = "%s" + cloud_provider = "%s" + severity = "%s" + remediation_info = [%s] + logic = < 0 { + filter = strings.Join(filters, "+") + } + + if filter != "" { + queryParams.Filter = &filter + } + } else { + queryParams.Filter = &fql + } + + for { + queryResp, err := r.client.CloudPolicies.QueryRule(&queryParams) + + if err != nil { + if badRequest, ok := err.(*cloud_policies.QueryRuleBadRequest); ok { + diags.AddError( + "Error Querying Rules", + fmt.Sprintf("Failed to query rules: %s", *badRequest.Payload.Errors[0].Message), + ) + return types.SetValueMust(types.ObjectType{AttrTypes: cloudPostureRulesDataSourceRuleModel{}.AttributeTypes()}, []attr.Value{}), diags + } + + if internalServerError, ok := err.(*cloud_policies.QueryRuleInternalServerError); ok { + diags.AddError( + "Error Querying Rules", + fmt.Sprintf("Failed to query rules: %s", *internalServerError.Payload.Errors[0].Message), + ) + return defaultResponse, diags + } + + diags.AddError( + "Error Querying Rules", + fmt.Sprintf("Failed to query rules: %s", err), + ) + + return defaultResponse, diags + } + + if queryResp == nil || queryResp.Payload == nil || len(queryResp.Payload.Resources) == 0 { + return defaultResponse, diags + } + + queryPayload := queryResp.GetPayload() + + if err = falcon.AssertNoError(queryPayload.Errors); err != nil { + diags.AddError( + "Error Querying Rules", + fmt.Sprintf("Failed to query rules: %s", err.Error()), + ) + return defaultResponse, diags + } + + if len(queryPayload.Resources) == 0 { + return defaultResponse, diags + } + + ruleParams := cloud_policies.GetRuleParams{ + Context: ctx, + Ids: queryPayload.Resources, + } + + getRulesResp, err := r.client.CloudPolicies.GetRule(&ruleParams) + if err != nil { + if !strings.Contains(err.Error(), "rule resource doesn't exist") { + diags.AddError( + "Failed to Fetch Rule Information", + fmt.Sprintf("Failed to fetch rule information: %s", err), + ) + } + return defaultResponse, diags + } + + if getRulesResp == nil || getRulesResp.Payload == nil || len(getRulesResp.Payload.Resources) == 0 { + diags.AddError( + "Error Fetching Rule Information", + "Failed to fetch rule information: The API returned an empty payload.", + ) + return defaultResponse, diags + } + + getRulesPayload := getRulesResp.GetPayload() + + if err = falcon.AssertNoError(getRulesPayload.Errors); err != nil { + diags.AddError( + "Error Fetching Rule Information", + fmt.Sprintf("Failed to fetch rule information: %s", err.Error()), + ) + return defaultResponse, diags + } + + for _, resource := range getRulesPayload.Resources { + rule := cloudPostureRulesDataSourceRuleModel{ + ID: types.StringValue(*resource.UUID), + Description: types.StringPointerValue(resource.Description), + AutoRemediable: types.BoolPointerValue(resource.AutoRemediable), + Domain: types.StringPointerValue(resource.Domain), + Logic: types.StringValue(resource.Logic), + Name: types.StringPointerValue(resource.Name), + ParentRuleID: types.StringValue(resource.ParentRuleShortUUID), + CloudPlatform: types.StringValue(resource.Platform), + CloudProvider: types.StringPointerValue(resource.Provider), + Severity: types.StringValue(int64ToSeverity[*resource.Severity]), + Subdomain: types.StringPointerValue(resource.Subdomain), + } + + var policyControls []policyControl + for _, control := range resource.Controls { + policyControls = append(policyControls, policyControl{ + Authority: types.StringPointerValue(control.Authority), + Code: types.StringPointerValue(control.Code), + }) + } + + rule.Controls, diags = types.SetValueFrom( + ctx, + types.ObjectType{AttrTypes: policyControl{}.AttributeTypes()}, + policyControls, + ) + + if diags.HasError() { + return defaultResponse, diags + } + + rule.AttackTypes, diags = types.SetValueFrom(ctx, types.StringType, resource.AttackTypes) + if diags.HasError() { + return defaultResponse, diags + } + + if resource.RuleLogicList != nil { + rule.RemediationInfo = convertAlertRemediationInfoToTerraformState(resource.RuleLogicList[0].RemediationInfo) + } + + if resource.AlertInfo != nil { + rule.AlertInfo = convertAlertRemediationInfoToTerraformState(resource.AlertInfo) + } + + if resource.ResourceTypes != nil { + rule.ResourceType = types.StringPointerValue(resource.ResourceTypes[0].ResourceType) + } + + rules = append(rules, rule) + } + + if queryPayload.Meta != nil && queryPayload.Meta.Pagination != nil { + pagination := queryPayload.Meta.Pagination + if pagination.Offset != nil && pagination.Total != nil && *pagination.Offset >= int32(*pagination.Total) { + tflog.Info(ctx, "Pagination complete", map[string]any{"meta": queryPayload.Meta}) + break + } + } + + offset += limit + } + + rulesSet, diags := types.SetValueFrom( + ctx, + types.ObjectType{AttrTypes: cloudPostureRulesDataSourceRuleModel{}.AttributeTypes()}, + rules, + ) + if diags.HasError() { + return defaultResponse, diags + } + return rulesSet, diags +} diff --git a/internal/cloud_posture/rules_data_source_test.go b/internal/cloud_posture/rules_data_source_test.go new file mode 100644 index 0000000..a4d3853 --- /dev/null +++ b/internal/cloud_posture/rules_data_source_test.go @@ -0,0 +1,200 @@ +package cloudposture_test + +import ( + "fmt" + "regexp" + "testing" + + "github.com/crowdstrike/terraform-provider-crowdstrike/internal/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +type dataRuleConfig struct { + cloudProvider string + ruleName string + resourceType string + benchmark string + framework string + service string +} + +var awsConfig = dataRuleConfig{ + cloudProvider: "AWS", + ruleName: "NLB/ALB configured publicly with TLS/SSL disabled", + resourceType: "AWS::ElasticLoadBalancingV2::LoadBalancer", + benchmark: "CIS*", + framework: "CIS", + service: "ELB", +} + +var azureConfig = dataRuleConfig{ + cloudProvider: "Azure", + ruleName: "Virtual Machine allows public internet access to Docker (port 2375/2376)", + resourceType: "Microsoft.Compute/virtualMachines", + benchmark: "CIS*", + framework: "CIS", + service: "Virtual Machines", +} + +var gcpConfig = dataRuleConfig{ + cloudProvider: "GCP", + ruleName: "GKE Cluster insecure kubelet read only port is enabled", + resourceType: "container.googleapis.com/Cluster", + benchmark: "CIS*", + framework: "CIS", + service: "Google Kubernetes Engine", +} + +func TestCloudPostureRulesDataSource(t *testing.T) { + var steps []resource.TestStep + + steps = append(steps, testCloudRules(awsConfig)...) + steps = append(steps, testCloudRules(azureConfig)...) + steps = append(steps, testCloudRules(gcpConfig)...) + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + PreCheck: func() { acctest.PreCheck(t) }, + Steps: steps, + }) +} + +func testCloudRules(config dataRuleConfig) (steps []resource.TestStep) { + resourceName := fmt.Sprintf("data.crowdstrike_cloud_posture_rules.%s", config.cloudProvider) + steps = []resource.TestStep{ + { + Config: fmt.Sprintf(` +data "crowdstrike_cloud_posture_rules" "%[1]s" { + cloud_provider = "%[1]s" + resource_type = "%[2]s" +} +`, config.cloudProvider, config.resourceType), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(resourceName, "rules.#"), + resource.TestMatchResourceAttr(resourceName, "rules.#", regexp.MustCompile(`^[2-9]|\d{2,}$`)), + func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + UUIDs := []string{} + for i := 0; ; i++ { + key := fmt.Sprintf("rules.%d.id", i) + if v, ok := rs.Primary.Attributes[key]; ok { + UUIDs = append(UUIDs, v) + } else { + break + } + } + for i, uuid := range UUIDs { + if v, ok := rs.Primary.Attributes[fmt.Sprintf("rules.%d.id", i)]; !ok || v != uuid { + return fmt.Errorf("Expected Id %s for rule %d, got %s", uuid, i, v) + } + } + return nil + }, + ), + }, + { + Config: fmt.Sprintf(` +data "crowdstrike_cloud_posture_rules" "%[1]s" { + cloud_provider = "%[1]s" + rule_name = "%[2]s" +} +`, config.cloudProvider, config.ruleName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "rules.#", "1"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.remediation_info.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.alert_info.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.severity"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.domain"), + resource.TestCheckResourceAttr(resourceName, "rules.0.name", config.ruleName), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.controls.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.cloud_provider"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.attack_types.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.resource_type"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.subdomain"), + func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + if v, ok := rs.Primary.Attributes["rules.0.id"]; !ok || v == "" { + return fmt.Errorf("Id not set for rule") + } + return nil + }, + ), + }, + { + Config: fmt.Sprintf(` +data "crowdstrike_cloud_posture_rules" "%[1]s" { + cloud_provider = "%[1]s" + rule_name = "%[2]s" + benchmark = "%[3]s" + framework = "%[4]s" + service = "%[5]s" +} +`, config.cloudProvider, config.ruleName, config.benchmark, config.framework, config.service), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "rules.#", "1"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.remediation_info.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.alert_info.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.severity"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.domain"), + resource.TestCheckResourceAttr(resourceName, "rules.0.name", config.ruleName), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.controls.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.cloud_provider"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.attack_types.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.resource_type"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.subdomain"), + func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + if v, ok := rs.Primary.Attributes["rules.0.id"]; !ok || v == "" { + return fmt.Errorf("Id not set for rule") + } + return nil + }, + ), + }, + { + Config: fmt.Sprintf(` +data "crowdstrike_cloud_posture_rules" "%s" { + fql = "rule_name:'%s'" +} +`, config.cloudProvider, config.ruleName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "rules.#", "1"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.id"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.remediation_info.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.alert_info.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.severity"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.domain"), + resource.TestCheckResourceAttr(resourceName, "rules.0.name", config.ruleName), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.controls.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.cloud_provider"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.attack_types.#"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.resource_type"), + resource.TestCheckResourceAttrSet(resourceName, "rules.0.subdomain"), + func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + if v, ok := rs.Primary.Attributes["rules.0.id"]; !ok || v == "" { + return fmt.Errorf("Id not set for rule") + } + return nil + }, + ), + }, + } + + return steps +} diff --git a/internal/cloud_posture/scopes.go b/internal/cloud_posture/scopes.go new file mode 100644 index 0000000..83e9908 --- /dev/null +++ b/internal/cloud_posture/scopes.go @@ -0,0 +1,11 @@ +package cloudposture + +import "github.com/crowdstrike/terraform-provider-crowdstrike/internal/scopes" + +var cloudPostureRuleScopes = []scopes.Scope{ + { + Name: "Cloud Security Policies", + Read: true, + Write: true, + }, +} diff --git a/internal/cloud_posture/shared.go b/internal/cloud_posture/shared.go new file mode 100644 index 0000000..69893f2 --- /dev/null +++ b/internal/cloud_posture/shared.go @@ -0,0 +1,74 @@ +package cloudposture + +import ( + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +const ( + SeverityCritical = "critical" + SeverityHigh = "high" + SeverityMedium = "medium" + SeverityInformational = "informational" +) + +var ( + severityToInt32 = map[string]int32{ + SeverityCritical: 0, + SeverityHigh: 1, + SeverityMedium: 2, + SeverityInformational: 3, + } + severityToInt64 = map[string]int64{ + SeverityCritical: 0, + SeverityHigh: 1, + SeverityMedium: 2, + SeverityInformational: 3, + } + int32ToSeverity = map[int32]string{ + 0: SeverityCritical, + 1: SeverityHigh, + 2: SeverityMedium, + 3: SeverityInformational, + } + int64ToSeverity = map[int64]string{ + 0: SeverityCritical, + 1: SeverityHigh, + 2: SeverityMedium, + 3: SeverityInformational, + } +) + +type fqlFilters struct { + value string + field string +} + +func convertAlertRemediationInfoToTerraformState(input *string) basetypes.ListValue { + if input == nil { + return basetypes.NewListValueMust(basetypes.StringType{}, []attr.Value{}) + } + + *input = strings.TrimSpace(*input) + *input = strings.TrimSuffix(*input, "|") + + parts := strings.Split(*input, "|") + values := make([]attr.Value, 0, len(parts)) + + for index, part := range parts { + trimmed := strings.TrimSpace(part) + if after, ok := strings.CutPrefix(trimmed, fmt.Sprintf("Step %d. ", index+1)); ok { + trimmed = strings.TrimSpace(after) + } else { + trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, fmt.Sprintf("%d. ", index+1))) + } + if trimmed != "" { + values = append(values, basetypes.NewStringValue(trimmed)) + } + } + + return basetypes.NewListValueMust(basetypes.StringType{}, values) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b3af681..22a3ca4 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,6 +7,8 @@ import ( "os" "github.com/crowdstrike/gofalcon/falcon" + cloudcompliance "github.com/crowdstrike/terraform-provider-crowdstrike/internal/cloud_compliance" + cloudposture "github.com/crowdstrike/terraform-provider-crowdstrike/internal/cloud_posture" contentupdatepolicy "github.com/crowdstrike/terraform-provider-crowdstrike/internal/content_update_policy" "github.com/crowdstrike/terraform-provider-crowdstrike/internal/fcs" "github.com/crowdstrike/terraform-provider-crowdstrike/internal/fim" @@ -253,6 +255,7 @@ func (p *CrowdStrikeProvider) Resources(ctx context.Context) []func() resource.R contentupdatepolicy.NewDefaultContentUpdatePolicyResource, contentupdatepolicy.NewContentUpdatePolicyPrecedenceResource, sensorvisibilityexclusion.NewSensorVisibilityExclusionResource, + cloudposture.NewCloudPostureCustomRuleResource, } } @@ -261,6 +264,8 @@ func (p *CrowdStrikeProvider) DataSources(ctx context.Context) []func() datasour sensorupdatepolicy.NewSensorUpdateBuildsDataSource, fcs.NewCloudAwsAccountsDataSource, contentupdatepolicy.NewContentCategoryVersionsDataSource, + cloudposture.NewCloudPostureRulesDataSource, + cloudcompliance.NewCloudComplianceFrameworkControlDataSource, } }