diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 7d0383a..449001d 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -35,6 +35,7 @@ qpbi querypack Qwc scim +startswith Tcy testacc TEzu diff --git a/docs/resources/exception.md b/docs/resources/exception.md new file mode 100644 index 0000000..1d0699c --- /dev/null +++ b/docs/resources/exception.md @@ -0,0 +1,55 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "mondoo_exception Resource - terraform-provider-mondoo" +subcategory: "" +description: |- + Set custom exceptions for a Scope. +--- + +# mondoo_exception (Resource) + +Set custom exceptions for a Scope. + +## Example Usage + +```terraform +variable "space_id" { + type = string + description = "The ID of the mondoo space." +} + +provider "mondoo" { + region = "eu" + space = var.space_id +} + +data "mondoo_assets" "assets_data" { + space_id = var.space_id +} + +locals { + ssl_asset = [for asset in data.mondoo_assets.assets_data.assets : asset if startswith(asset.name, "https")] + asset_id = one(local.ssl_asset).id +} + + +resource "mondoo_exception" "exception" { + scope_mrn = "//assets.api.mondoo.app/spaces/${var.space_id}/assets/${local.asset_id}" + valid_until = "2024-12-11" + justification = "testing" + action = "SNOOZE" + check_mrns = ["//policy.api.mondoo.app/queries/mondoo-tls-security-mitigate-beast"] +} +``` + + +## Schema + +### Optional + +- `action` (String) The action to perform. Default is `SNOOZE`. Other options are `ENABLE`, `DISABLE`, `OUT_OF_SCOPE`. +- `check_mrns` (List of String) List of check MRNs to set exceptions for. If set, `vulnerability_mrns` must not be set. +- `justification` (String) Description why the exception is required. +- `scope_mrn` (String) The MRN of the scope (either asset mrn or space mrn). +- `valid_until` (String) The timestamp until the exception is valid. +- `vulnerability_mrns` (List of String) List of vulnerability MRNs to set exceptions for. If set, `check_mrns` must not be set. diff --git a/examples/resources/mondoo_exception/main.tf b/examples/resources/mondoo_exception/main.tf new file mode 100644 index 0000000..6aeddc8 --- /dev/null +++ b/examples/resources/mondoo_exception/main.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + mondoo = { + source = "mondoohq/mondoo" + version = ">= 0.19" + } + } +} diff --git a/examples/resources/mondoo_exception/resource.tf b/examples/resources/mondoo_exception/resource.tf new file mode 100644 index 0000000..b6c7d3f --- /dev/null +++ b/examples/resources/mondoo_exception/resource.tf @@ -0,0 +1,27 @@ +variable "space_id" { + type = string + description = "The ID of the mondoo space." +} + +provider "mondoo" { + region = "eu" + space = var.space_id +} + +data "mondoo_assets" "assets_data" { + space_id = var.space_id +} + +locals { + ssl_asset = [for asset in data.mondoo_assets.assets_data.assets : asset if startswith(asset.name, "https")] + asset_id = one(local.ssl_asset).id +} + + +resource "mondoo_exception" "exception" { + scope_mrn = "//assets.api.mondoo.app/spaces/${var.space_id}/assets/${local.asset_id}" + valid_until = "2024-12-11" + justification = "testing" + action = "SNOOZE" + check_mrns = ["//policy.api.mondoo.app/queries/mondoo-tls-security-mitigate-beast"] +} diff --git a/internal/provider/exception_resource.go b/internal/provider/exception_resource.go new file mode 100644 index 0000000..bd4179f --- /dev/null +++ b/internal/provider/exception_resource.go @@ -0,0 +1,319 @@ +package provider + +import ( + "context" + "fmt" + "regexp" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/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-log/tflog" + mondoov1 "go.mondoo.com/mondoo-go" +) + +var _ resource.Resource = (*exceptionResource)(nil) + +func NewExceptionResource() resource.Resource { + return &exceptionResource{} +} + +// parseDate parses a date string in the format "YYYY-MM-DD" and returns the year, month, and day as integers. +func parseDate(dateStr string) (int, time.Month, int, error) { + t, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return 0, 0, 0, err + } + return t.Year(), t.Month(), t.Day(), nil +} + +type exceptionResource struct { + client *ExtendedGqlClient +} + +type exceptionResourceModel struct { + ScopeMrn types.String `tfsdk:"scope_mrn"` + ValidUntil types.String `tfsdk:"valid_until"` + Justification types.String `tfsdk:"justification"` + Action types.String `tfsdk:"action"` + CheckMrns types.List `tfsdk:"check_mrns"` + VulnerabilityMrns types.List `tfsdk:"vulnerability_mrns"` +} + +func (r *exceptionResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_exception" +} + +func (r *exceptionResource) GetConfigurationOptions(ctx context.Context, data *exceptionResourceModel) (scopeMrn string, checks []string, vulnerabilities []string, validUntilStr string, err error) { + // Extract ScopeMrn + scopeMrn = data.ScopeMrn.ValueString() + if scopeMrn == "" { + scopeMrn = r.client.space.MRN() + } + + // Extract Checks and Vulnerabilities + checks = []string{} + data.CheckMrns.ElementsAs(ctx, &checks, false) + + vulnerabilities = []string{} + data.VulnerabilityMrns.ElementsAs(ctx, &vulnerabilities, false) + + // Format ValidUntil to RFC3339 if provided + validUntil := data.ValidUntil.ValueString() + if validUntil != "" { + year, month, day, parseErr := parseDate(validUntil) + if parseErr != nil { + return "", nil, nil, "", parseErr + } + now := time.Now().UTC() // Use UTC directly + validUntilStr = time.Date( + year, + month, + day, + now.Hour(), + now.Minute(), + now.Second(), + now.Nanosecond(), + time.UTC, + ).Format(time.RFC3339Nano) // Use RFC3339Nano to include nanoseconds + } + + return scopeMrn, checks, vulnerabilities, validUntilStr, nil +} + +// ValidUntilValidator ensures the "valid_until" attribute is only set when "action" is "SNOOZE". +type ValidUntilValidator struct{} + +// ValidateString performs the validation for the "valid_until" attribute. +func (v ValidUntilValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + // Retrieve the "action" attribute value from the attribute path + var actionAttr types.String + err := req.Config.GetAttribute(ctx, path.Root("action"), &actionAttr) + if err != nil || actionAttr.IsNull() { + return // If "action" is not set or there's an error, nothing to validate + } + + if actionAttr.ValueString() != "SNOOZE" && !req.ConfigValue.IsNull() { + resp.Diagnostics.AddAttributeError( + req.Path, + "'valid_until' Can Only Be Set with 'action' as 'SNOOZE'", + "To use 'valid_until', the 'action' attribute must be set to 'SNOOZE'. Either remove 'valid_until' or change 'action' to 'SNOOZE'.", + ) + } +} + +// Description returns a plain-text description of the validator's purpose. +func (v ValidUntilValidator) Description(ctx context.Context) string { + return "'valid_until' can only be set if 'action' is set to 'SNOOZE'." +} + +// MarkdownDescription returns a markdown-formatted description of the validator's purpose. +func (v ValidUntilValidator) MarkdownDescription(ctx context.Context) string { + return "'valid_until' can only be set if 'action' is set to `SNOOZE`." +} + +// NewValidUntilValidator is a convenience function for creating an instance of the validator. +func NewValidUntilValidator() validator.String { + return &ValidUntilValidator{} +} + +func (r *exceptionResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: `Set custom exceptions for a Scope.`, + Attributes: map[string]schema.Attribute{ + "scope_mrn": schema.StringAttribute{ + MarkdownDescription: "The MRN of the scope (either asset mrn or space mrn).", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "valid_until": schema.StringAttribute{ + MarkdownDescription: "The timestamp until the exception is valid.", + Optional: true, + Validators: []validator.String{ + stringvalidator.RegexMatches(regexp.MustCompile(`[1-9][0-9][0-9]{2}-([0][1-9]|[1][0-2])-([1-2][0-9]|[0][1-9]|[3][0-1])`), "Date must be in the format 'YYYY-MM-DD'"), + NewValidUntilValidator(), + }, + }, + "justification": schema.StringAttribute{ + MarkdownDescription: "Description why the exception is required.", + Optional: true, + }, + "action": schema.StringAttribute{ + MarkdownDescription: "The action to perform. Default is `SNOOZE`. Other options are `ENABLE`, `DISABLE`, `OUT_OF_SCOPE`.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("SNOOZE"), + Validators: []validator.String{ + stringvalidator.OneOf("SNOOZE", "ENABLE", "DISABLE", "OUT_OF_SCOPE"), + }, + }, + "check_mrns": schema.ListAttribute{ + MarkdownDescription: "List of check MRNs to set exceptions for. If set, `vulnerability_mrns` must not be set.", + ElementType: types.StringType, + Optional: true, + Validators: []validator.List{ + listvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("vulnerability_mrns"), + }...), + listvalidator.ExactlyOneOf(path.MatchRoot("check_mrns"), path.MatchRoot("vulnerability_mrns")), + }, + }, + "vulnerability_mrns": schema.ListAttribute{ + MarkdownDescription: "List of vulnerability MRNs to set exceptions for. If set, `check_mrns` must not be set.", + ElementType: types.StringType, + Optional: true, + Validators: []validator.List{ + listvalidator.ConflictsWith(path.Expressions{ + path.MatchRoot("check_mrns"), + }...), + listvalidator.ExactlyOneOf(path.MatchRoot("check_mrns"), path.MatchRoot("vulnerability_mrns")), + }, + }, + }, + } +} + +func (r *exceptionResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*ExtendedGqlClient) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func (r *exceptionResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data exceptionResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + scopeMrn, checks, vulnerabilities, validUntilStr, err := r.GetConfigurationOptions(ctx, &data) + if err != nil { + resp.Diagnostics.AddError("Invalid Configuration", err.Error()) + return + } + + // disable existing exceptions + tflog.Debug(ctx, fmt.Sprintf("Creating exception for scope %s", data.ScopeMrn.ValueString())) + err = r.client.ApplyException(ctx, scopeMrn, mondoov1.ExceptionMutationActionEnable, checks, []string{}, []string{}, vulnerabilities, (*string)(mondoov1.NewStringPtr("")), (*string)(mondoov1.NewStringPtr("")), (*bool)(mondoov1.NewBooleanPtr(false))) + if err != nil { + resp.Diagnostics.AddError("Failed to disable existing exception", err.Error()) + return + } + + // Create API call logic + tflog.Debug(ctx, fmt.Sprintf("Creating exception for scope %s", data.ScopeMrn.ValueString())) + err = r.client.ApplyException(ctx, scopeMrn, mondoov1.ExceptionMutationAction(data.Action.ValueString()), checks, []string{}, []string{}, vulnerabilities, data.Justification.ValueStringPointer(), &validUntilStr, (*bool)(mondoov1.NewBooleanPtr(false))) + if err != nil { + resp.Diagnostics.AddError("Failed to create exception", err.Error()) + return + } + + data.ScopeMrn = types.StringValue(scopeMrn) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *exceptionResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data exceptionResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // Read API call logic + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *exceptionResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data exceptionResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + _, checks, vulnerabilities, validUntilStr, err := r.GetConfigurationOptions(ctx, &data) + if err != nil { + resp.Diagnostics.AddError("Invalid Configuration", err.Error()) + return + } + + tflog.Debug(ctx, fmt.Sprintf("Deleting exception for scope %s", data.ScopeMrn.ValueString())) + err = r.client.ApplyException(ctx, data.ScopeMrn.ValueString(), mondoov1.ExceptionMutationActionEnable, checks, []string{}, []string{}, vulnerabilities, (*string)(mondoov1.NewStringPtr("")), (*string)(mondoov1.NewStringPtr("")), (*bool)(mondoov1.NewBooleanPtr(false))) + if err != nil { + resp.Diagnostics.AddError("Failed to disable existing exception", err.Error()) + return + } + + // Update API call logic + tflog.Debug(ctx, fmt.Sprintf("Creating exception for scope %s", data.ScopeMrn.ValueString())) + err = r.client.ApplyException(ctx, data.ScopeMrn.ValueString(), mondoov1.ExceptionMutationAction(data.Action.ValueString()), checks, []string{}, []string{}, vulnerabilities, data.Justification.ValueStringPointer(), &validUntilStr, (*bool)(mondoov1.NewBooleanPtr(false))) + if err != nil { + resp.Diagnostics.AddError("Failed to update exception", err.Error()) + return + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *exceptionResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data exceptionResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + _, checks, vulnerabilities, _, err := r.GetConfigurationOptions(ctx, &data) + if err != nil { + resp.Diagnostics.AddError("Invalid Configuration", err.Error()) + return + } + + // Delete API call logic + tflog.Debug(ctx, fmt.Sprintf("Deleting exception for scope %s", data.ScopeMrn.ValueString())) + err = r.client.ApplyException(ctx, data.ScopeMrn.ValueString(), mondoov1.ExceptionMutationActionEnable, checks, []string{}, []string{}, vulnerabilities, (*string)(mondoov1.NewStringPtr("")), (*string)(mondoov1.NewStringPtr("")), (*bool)(mondoov1.NewBooleanPtr(false))) + if err != nil { + resp.Diagnostics.AddError("Failed to delete exception", err.Error()) + return + } +} diff --git a/internal/provider/gql.go b/internal/provider/gql.go index 48eaafc..c1f1110 100644 --- a/internal/provider/gql.go +++ b/internal/provider/gql.go @@ -990,3 +990,44 @@ func (c *ExtendedGqlClient) ImportIntegration(ctx context.Context, req resource. return &integration, true } + +func (c *ExtendedGqlClient) ApplyException( + ctx context.Context, + scopeMrn string, + action mondoov1.ExceptionMutationAction, + checkMrns, controlMrns, cveMrns, vulnerabilityMrns []string, + justification *string, + validUntil *string, + applyToCves *bool, +) error { + var applyException struct { + ApplyException bool `graphql:"applyException(input: $input)"` + } + + // Helper function to convert string slices to *[]mondoov1.String + convertToGraphQLList := func(mrns []string) *[]mondoov1.String { + if len(mrns) == 0 { + return nil + } + entries := []mondoov1.String{} + for _, mrn := range mrns { + entries = append(entries, mondoov1.String(mrn)) + } + return &entries + } + + // Prepare input fields + input := mondoov1.ExceptionMutationInput{ + ScopeMrn: mondoov1.String(scopeMrn), + Action: action, + QueryMrns: convertToGraphQLList(checkMrns), + ControlMrns: convertToGraphQLList(controlMrns), + CveMrns: convertToGraphQLList(cveMrns), + AdvisoryMrns: convertToGraphQLList(vulnerabilityMrns), + Justification: (*mondoov1.String)(justification), + ValidUntil: (*mondoov1.String)(validUntil), + ApplyToCves: mondoov1.NewBooleanPtr(mondoov1.Boolean(*applyToCves)), + } + + return c.Mutate(ctx, &applyException, input, nil) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1624c46..cedfe93 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -208,6 +208,7 @@ func (p *MondooProvider) Resources(ctx context.Context) []func() resource.Resour NewIntegrationJiraResource, NewIntegrationEmailResource, NewIntegrationGitlabResource, + NewExceptionResource, NewIntegrationMsDefenderResource, } }