From a5c094a71f4c9dd9bb413594fa9b5b201c380879 Mon Sep 17 00:00:00 2001 From: Evan Anderson Date: Tue, 30 Jun 2026 19:11:57 -0700 Subject: [PATCH 1/5] Feature: support ruletype definitions as rego + metadata --- cmd/cli/app/apply.go | 52 ++++++-- cmd/cli/app/apply_test.go | 94 +++++++++++++ cmd/cli/app/fixture/applied_rule.rego | 35 +++++ cmd/cli/app/fixture/empty_file.yaml | 0 .../app/fixture/mock_profiles_response.json | 60 +++++++++ .../app/fixture/mock_ruletypes_response.json | 79 +++++++++++ cmd/cli/app/fixture/profile.yaml | 14 ++ cmd/cli/app/fixture/rule_type_sample.yaml | 43 ++++++ cmd/cli/app/ruletype/common.go | 64 ++++----- .../app/ruletype/fixture/applied_rule.rego | 34 +++++ .../app/ruletype/fixture/rule_type_apply.yaml | 8 +- .../ruletype/fixture/rule_type_sample.yaml | 8 +- cmd/cli/app/ruletype/ruletype_apply.go | 2 +- cmd/cli/app/ruletype/ruletype_apply_test.go | 17 +++ cmd/cli/app/ruletype/ruletype_create.go | 4 +- cmd/cli/app/ruletype/ruletype_create_test.go | 19 +++ .../app/testdata/apply_create.table.golden | 2 + pkg/fileconvert/collect.go | 65 +++++---- pkg/fileconvert/encodedecode.go | 2 + pkg/fileconvert/fileconvert_test.go | 2 +- pkg/fileconvert/rego_parser.go | 126 ++++++++++++++++++ .../testdata/directory/ruletype.rego | 37 +++++ pkg/fileconvert/testdata/resources.yaml | 63 +++++++++ 23 files changed, 750 insertions(+), 80 deletions(-) create mode 100644 cmd/cli/app/apply_test.go create mode 100644 cmd/cli/app/fixture/applied_rule.rego create mode 100644 cmd/cli/app/fixture/empty_file.yaml create mode 100644 cmd/cli/app/fixture/mock_profiles_response.json create mode 100644 cmd/cli/app/fixture/mock_ruletypes_response.json create mode 100644 cmd/cli/app/fixture/profile.yaml create mode 100644 cmd/cli/app/fixture/rule_type_sample.yaml create mode 100644 cmd/cli/app/ruletype/fixture/applied_rule.rego create mode 100644 cmd/cli/app/testdata/apply_create.table.golden create mode 100644 pkg/fileconvert/rego_parser.go create mode 100644 pkg/fileconvert/testdata/directory/ruletype.rego diff --git a/cmd/cli/app/apply.go b/cmd/cli/app/apply.go index a4287705c7..b3dbdfe427 100644 --- a/cmd/cli/app/apply.go +++ b/cmd/cli/app/apply.go @@ -4,12 +4,11 @@ package app import ( - "context" + "errors" "fmt" "github.com/spf13/cobra" "github.com/spf13/viper" - "google.golang.org/grpc" "github.com/mindersec/minder/internal/util/cli" "github.com/mindersec/minder/pkg/api" @@ -21,21 +20,37 @@ var applyCmd = &cobra.Command{ Use: "apply", Short: "Apply multiple minder resources", Long: `The apply subcommand lets you apply multiple Minder resources at once.`, - RunE: cli.GRPCClientWrapRunE(applyCommand), + PreRunE: func(cmd *cobra.Command, _ []string) error { + if err := viper.BindPFlags(cmd.Flags()); err != nil { + return fmt.Errorf("error binding flags: %s", err) + } + + return nil + + }, + RunE: applyCommand, } // applyCommand is the general-purpose "apply" subcommand -func applyCommand(ctx context.Context, cmd *cobra.Command, args []string, conn *grpc.ClientConn) error { +func applyCommand(cmd *cobra.Command, args []string) error { //ctx context.Context, cmd *cobra.Command, args []string, conn *grpc.ClientConn) error { // Step 1: Collect inputs, by reading files or directories. Use the "-f" flag if set, positional arguments otherwise. fileNames := args - if len(viper.GetStringSlice("file")) > 0 { - fileNames = viper.GetStringSlice("file") + argFiles, _ := cmd.Flags().GetStringSlice("file") + if len(argFiles) > 0 { + fileNames = argFiles } objects, err := fileconvert.ResourcesFromPaths(cmd.Printf, fileNames...) if err != nil { return cli.MessageAndError("Error reading resources", err) } + if len(objects) == 0 { + return errors.New("no resources found") + } + // No longer print usage on returned error, since we've parsed our inputs + // See https://github.com/spf13/cobra/issues/340#issuecomment-374617413 + cmd.SilenceUsage = true + // Step 2: sort objects by type var profiles []*minderv1.Profile var ruleTypes []*minderv1.RuleType @@ -67,23 +82,35 @@ func applyCommand(ctx context.Context, cmd *cobra.Command, args []string, conn * } // Step 3: apply objects, starting with DataSources - dataSourceClient := minderv1.NewDataSourceServiceClient(conn) + dataSourceClient, dsClose, err := cli.GetCLIClient(cmd, minderv1.NewDataSourceServiceClient) + if err != nil { + return cli.MessageAndError("Error connecting to server", err) + } + defer dsClose() for _, dataSource := range dataSources { - if err := api.UpsertDataSource(ctx, dataSourceClient, dataSource); err != nil { + if err := api.UpsertDataSource(cmd.Context(), dataSourceClient, dataSource); err != nil { return cli.MessageAndError(fmt.Sprintf("Unable to create datasource %s", dataSource.Name), err) } } - ruleTypeClient := minderv1.NewRuleTypeServiceClient(conn) + ruleTypeClient, rtClose, err := cli.GetCLIClient(cmd, minderv1.NewRuleTypeServiceClient) + if err != nil { + return cli.MessageAndError("Error connecting to server", err) + } + defer rtClose() for _, ruleType := range ruleTypes { - if err := api.UpsertRuleType(ctx, ruleTypeClient, ruleType); err != nil { + if err := api.UpsertRuleType(cmd.Context(), ruleTypeClient, ruleType); err != nil { return cli.MessageAndError(fmt.Sprintf("Unable to create ruletype %s", ruleType.Name), err) } } - profileClient := minderv1.NewProfileServiceClient(conn) + profileClient, pClose, err := cli.GetCLIClient(cmd, minderv1.NewProfileServiceClient) + if err != nil { + return cli.MessageAndError("Error connecting to server", err) + } + defer pClose() for _, profile := range profiles { - if err := api.UpsertProfile(ctx, profileClient, profile); err != nil { + if err := api.UpsertProfile(cmd.Context(), profileClient, profile); err != nil { return cli.MessageAndError(fmt.Sprintf("Unable to create profile %s", profile.Name), err) } } @@ -94,5 +121,6 @@ func applyCommand(ctx context.Context, cmd *cobra.Command, args []string, conn * func init() { RootCmd.AddCommand(applyCmd) // Flags + applyCmd.Flags().StringP("project", "j", "", "ID of the project") applyCmd.Flags().StringSliceP("file", "f", []string{}, "Input file or directory") } diff --git a/cmd/cli/app/apply_test.go b/cmd/cli/app/apply_test.go new file mode 100644 index 0000000000..0034338116 --- /dev/null +++ b/cmd/cli/app/apply_test.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2023 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +package app + +import ( + "context" + "path/filepath" + "testing" + + "go.uber.org/mock/gomock" + + "github.com/mindersec/minder/internal/util/cli" + minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" + mockv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1/mock" +) + +//nolint:paralleltest // Cannot run in parallel because it swaps global Viper/Stdout state +func TestApplyCommand(t *testing.T) { + tests := []cli.CmdTestCase{ + { + Name: "apply - create profile and ruletypes with positional arg", + Args: []string{"apply", "fixture"}, + MockSetup: func(t *testing.T, ctrl *gomock.Controller) context.Context { + t.Helper() + dsClient := mockv1.NewMockDataSourceServiceClient(ctrl) + rtClient := mockv1.NewMockRuleTypeServiceClient(ctrl) + pClient := mockv1.NewMockProfileServiceClient(ctrl) + mockRTResp := &minderv1.ListRuleTypesResponse{} + mockProfileResp := &minderv1.ListProfilesResponse{} + cli.LoadFixture(t, "mock_ruletypes_response.json", mockRTResp) + cli.LoadFixture(t, "mock_profiles_response.json", mockProfileResp) + + rtClient.EXPECT(). + CreateRuleType(gomock.Any(), gomock.Any()). + Return(&minderv1.CreateRuleTypeResponse{RuleType: mockRTResp.RuleTypes[0]}, nil) + rtClient.EXPECT(). + CreateRuleType(gomock.Any(), gomock.Any()). + Return(&minderv1.CreateRuleTypeResponse{RuleType: mockRTResp.RuleTypes[1]}, nil) + pClient.EXPECT(). + CreateProfile(gomock.Any(), gomock.Any()). + Return(&minderv1.CreateProfileResponse{Profile: mockProfileResp.Profiles[0]}, nil) + ctx := context.Background() + ctx = cli.WithRPCClient[minderv1.DataSourceServiceClient](ctx, dsClient) + ctx = cli.WithRPCClient[minderv1.RuleTypeServiceClient](ctx, rtClient) + ctx = cli.WithRPCClient[minderv1.ProfileServiceClient](ctx, pClient) + return ctx + }, + GoldenFileName: "apply_create.table", + }, + { + Name: "apply - create profile and ruletypes with flag", + Args: []string{"apply", "-f", "fixture/"}, + MockSetup: func(t *testing.T, ctrl *gomock.Controller) context.Context { + t.Helper() + dsClient := mockv1.NewMockDataSourceServiceClient(ctrl) + rtClient := mockv1.NewMockRuleTypeServiceClient(ctrl) + pClient := mockv1.NewMockProfileServiceClient(ctrl) + mockRTResp := &minderv1.ListRuleTypesResponse{} + mockProfileResp := &minderv1.ListProfilesResponse{} + cli.LoadFixture(t, "mock_ruletypes_response.json", mockRTResp) + cli.LoadFixture(t, "mock_profiles_response.json", mockProfileResp) + + rtClient.EXPECT(). + CreateRuleType(gomock.Any(), gomock.Any()). + Return(&minderv1.CreateRuleTypeResponse{RuleType: mockRTResp.RuleTypes[0]}, nil) + rtClient.EXPECT(). + CreateRuleType(gomock.Any(), gomock.Any()). + Return(&minderv1.CreateRuleTypeResponse{RuleType: mockRTResp.RuleTypes[1]}, nil) + pClient.EXPECT(). + CreateProfile(gomock.Any(), gomock.Any()). + Return(&minderv1.CreateProfileResponse{Profile: mockProfileResp.Profiles[0]}, nil) + ctx := context.Background() + ctx = cli.WithRPCClient[minderv1.DataSourceServiceClient](ctx, dsClient) + ctx = cli.WithRPCClient[minderv1.RuleTypeServiceClient](ctx, rtClient) + ctx = cli.WithRPCClient[minderv1.ProfileServiceClient](ctx, pClient) + return ctx + }, + GoldenFileName: "apply_create.table", + }, + { + Name: "no files specified", + Args: []string{"apply"}, + ExpectedError: "no resources found", + }, + { + Name: "only empty files", + Args: []string{"apply", filepath.Join("fixture", "empty_file.yaml")}, + ExpectedError: "no resources found", + }, + } + + cli.RunCmdTests(t, tests, applyCmd) +} diff --git a/cmd/cli/app/fixture/applied_rule.rego b/cmd/cli/app/fixture/applied_rule.rego new file mode 100644 index 0000000000..aab0d90ab2 --- /dev/null +++ b/cmd/cli/app/fixture/applied_rule.rego @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright 2026 The Minder Authors +# SPDX-License-Identifier: Apache-2.0 + +package minder + +# METADATA +# +# title: 'Applied Rule' +# description: 'A minimal test rule type.' +# custom: +# release_phase: alpha +# severity: +# value: low +# guidance: Do it! +# def: +# in_entity: repository +# +# # How to gather data (Minimal REST example) +# ingest: +# type: rest +# rest: +# endpoint: '/repos/{{.Entity.Owner}}/{{.Entity.Name}}' +# parse: 'json' +# +# eval: +# rego: +# type: 'deny-by-default' +package minder + +import rego.v1 + +# How to evaluate the data (Minimal Rego example that always passes) +default allow := false + +allow := true \ No newline at end of file diff --git a/cmd/cli/app/fixture/empty_file.yaml b/cmd/cli/app/fixture/empty_file.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cmd/cli/app/fixture/mock_profiles_response.json b/cmd/cli/app/fixture/mock_profiles_response.json new file mode 100644 index 0000000000..81cf41a417 --- /dev/null +++ b/cmd/cli/app/fixture/mock_profiles_response.json @@ -0,0 +1,60 @@ +{ + "profiles": [ + { + "context": { + "project": "00000000-0000-0000-0000-000000000000" + }, + "id": "11111111-1111-1111-1111-111111111111", + "name": "mock-artifact-profile", + "artifact": [ + { + "type": "artifact_signature", + "params": { + "name": "mock-artifact", + "tags": [ + "latest" + ] + }, + "def": { + "is_signed": true, + "is_verified": true + }, + "name": "Mock ensure artifacts are signed" + } + ], + "remediate": "off", + "alert": "on", + "displayName": "Mock Artifact Signature Profile" + }, + { + "context": { + "project": "00000000-0000-0000-0000-000000000000" + }, + "id": "22222222-2222-2222-2222-222222222222", + "name": "mock-branch-protection", + "repository": [ + { + "type": "branch_protection_enabled", + "params": { + "branch": "main" + }, + "def": {}, + "name": "Mock enable branch protection" + }, + { + "type": "branch_protection_require_pull_request_approving_review_count", + "params": { + "branch": "main" + }, + "def": { + "required_approving_review_count": 2 + }, + "name": "Mock require 2 reviews" + } + ], + "remediate": "on", + "alert": "off", + "displayName": "Mock Branch Protection Profile" + } + ] +} \ No newline at end of file diff --git a/cmd/cli/app/fixture/mock_ruletypes_response.json b/cmd/cli/app/fixture/mock_ruletypes_response.json new file mode 100644 index 0000000000..dae57501a1 --- /dev/null +++ b/cmd/cli/app/fixture/mock_ruletypes_response.json @@ -0,0 +1,79 @@ +{ + "ruleTypes": [ + { + "id": "00000000-0000-0000-0000-000000000001", + "name": "applied_rule", + "displayName": "Applied Rule", + "context": { + "provider": "", + "project": "00000000-0000-0000-0000-000000000000" + }, + "def": { + "inEntity": "repository", + "ruleSchema": { + }, + "ingest": { + "type": "rest", + "rest": { + "endpoint": "/repos/{{.Entity.Owner}}/{{.Entity.Name}}", + "parse": "json" + } + }, + "eval": { + "type": "rego", + "rego": { + "type": "deny-by-default", + "def": "package minder\n\n# METADATA\n# \n# title: 'Applied Rule'\n# ...\nimport rego.v1\n\ndefault allow := false\n\nallow := ntrue\n" + } + } + }, + "description": "A minimal test rule type", + "guidance": "Do it!", + "severity": { + "value": "VALUE_LOW" + }, + "releasePhase": "RULE_TYPE_RELEASE_PHASE_BETA" + }, + { + "id": "00000000-0000-0000-0000-000000000002", + "name": "branch_protection_reviews", + "displayName": "Require Branch Protection Reviews", + "shortFailureMessage": "Secret scanning is not enabled", + "context": { + "provider": "", + "project": "00000000-0000-0000-0000-000000000000" + }, + "def": { + "inEntity": "repository", + "ruleSchema": { + "properties": { + "required_reviews": { + "default": 2, + "type": "integer" + } + } + }, + "ingest": { + "type": "rest", + "rest": { + "endpoint": "/repos/{{.Entity.Owner}}/{{.Entity.Name}}/branches/main/protection", + "parse": "json" + } + }, + "eval": { + "type": "rego", + "rego": { + "type": "deny-by-default", + "def": "package minder\n\nimport rego.v1\n\ndefault allow := false\n\nallow if{\n #Check if the required review count meets our minimum\n input.ingested.required_pull_request_reviews.required_approving_review_count >= input.profile.required_reviews\n}\n" + } + } + }, + "description": "Ensures that branch protection requires at least two approved reviews.", + "guidance": "To comply with this rule, you must enable branch protection on your main branch \nand set the 'Required approving reviews' count to 2 or higher.\n", + "severity": { + "value": "VALUE_HIGH" + }, + "releasePhase": "RULE_TYPE_RELEASE_PHASE_BETA" + } + ] +} \ No newline at end of file diff --git a/cmd/cli/app/fixture/profile.yaml b/cmd/cli/app/fixture/profile.yaml new file mode 100644 index 0000000000..11b2dba1e1 --- /dev/null +++ b/cmd/cli/app/fixture/profile.yaml @@ -0,0 +1,14 @@ +# Copyright 2026 The Minder Authors +# SPDX-License-Identifier: Apache-2.0 + +version: v1 +type: profile +name: sample-profile +display_name: Sample Profile +repository: + - type: branch_protection_reviews + def: + package_ecosystem: mock-ecosystem + schedule_interval: weekly + apply_if_file: mock.mod + - type: applied_rule diff --git a/cmd/cli/app/fixture/rule_type_sample.yaml b/cmd/cli/app/fixture/rule_type_sample.yaml new file mode 100644 index 0000000000..341a4d5a55 --- /dev/null +++ b/cmd/cli/app/fixture/rule_type_sample.yaml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright 2026 The Minder Authors +# SPDX-License-Identifier: Apache-2.0 + +version: v1 +type: rule-type +name: branch_protection_reviews +context: + project: 00000000-0000-0000-0000-000000000000 +description: 'Ensures that branch protection requires at least two approved reviews.' +display_name: 'Require Branch Protection Reviews' +releasePhase: beta +severity: + value: high +guidance: | + To comply with this rule, you must enable branch protection on your main branch + and set the 'Required approving reviews' count to 2 or higher. +def: + in_entity: repository + rule_schema: + properties: + required_reviews: + type: integer + default: 2 + ingest: + type: rest + rest: + endpoint: '/repos/{{.Entity.Owner}}/{{.Entity.Name}}/branches/main/protection' + parse: 'json' + eval: + type: rego + rego: + type: 'deny-by-default' + def: | + package minder + + import rego.v1 + + default allow := false + + allow if { + # Check if the required review count meets our minimum + input.ingested.required_pull_request_reviews.required_approving_review_count >= input.profile.required_reviews + } diff --git a/cmd/cli/app/ruletype/common.go b/cmd/cli/app/ruletype/common.go index 17a421023f..ae527548d8 100644 --- a/cmd/cli/app/ruletype/common.go +++ b/cmd/cli/app/ruletype/common.go @@ -18,51 +18,53 @@ import ( "github.com/mindersec/minder/internal/util/cli/table" "github.com/mindersec/minder/internal/util/cli/table/layouts" minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" + "github.com/mindersec/minder/pkg/fileconvert" + "github.com/spf13/cobra" ) func execOnOneRuleType( - ctx context.Context, + cmd *cobra.Command, t table.Table, - f string, + f util.ExpandedFile, dashOpen io.Reader, proj string, exec func(context.Context, string, *minderv1.RuleType) (*minderv1.RuleType, error), ) error { - reader, closer, err := util.OpenFileArg(f, dashOpen) + resources, err := fileconvert.ReadFromPath(cmd.Printf, f) if err != nil { - return fmt.Errorf("error opening file arg: %w", err) - } - defer closer() - - r := &minderv1.RuleType{} - if err := minderv1.ParseResource(reader, r); err != nil { - return fmt.Errorf("error parsing rule type: %w", err) + return err } - // Override the YAML specified project with the command line argument - if proj != "" { - if r.Context == nil { - r.Context = &minderv1.Context{} + for _, resource := range resources { + r, ok := resource.(*minderv1.RuleType) + if !ok { + return fmt.Errorf("file is a different type of resource: %T", resource) } - r.Context.Project = &proj - } + // Override the YAML specified project with the command line argument + if proj != "" { + if r.Context == nil { + r.Context = &minderv1.Context{} + } - // create a rule - rt, err := exec(ctx, f, r) - if err != nil { - return err - } + r.Context.Project = &proj + } - // add the rule type to the table rows - name := appendRuleTypePropertiesToName(rt) + // create a rule + rt, err := exec(cmd.Context(), f.Path, r) + if err != nil { + return err + } - t.AddRow( - name, - rt.Def.InEntity, - rt.Description, - ) + // add the rule type to the table rows + name := appendRuleTypePropertiesToName(rt) + t.AddRow( + name, + rt.Def.InEntity, + rt.Description, + ) + } return nil } @@ -72,7 +74,7 @@ func validateFilesArg(files []string) error { } if slices.Contains(files, "") { - return fmt.Errorf("error: file must be set") + return fmt.Errorf("error: file must be nonempty") } if slices.Contains(files, "-") && len(files) > 1 { @@ -87,10 +89,10 @@ func shouldSkipFile(f string) bool { // Get file extension ext := filepath.Ext(f) switch ext { - case ".yaml", ".yml", ".json": + case ".yaml", ".yml", ".json", ".rego": return false default: - fmt.Fprintf(os.Stderr, "Skipping file %s: not a yaml or json file\n", f) + fmt.Fprintf(os.Stderr, "Skipping file %s: not a yaml, json or rego file\n", f) return true } } diff --git a/cmd/cli/app/ruletype/fixture/applied_rule.rego b/cmd/cli/app/ruletype/fixture/applied_rule.rego new file mode 100644 index 0000000000..88eab00a6b --- /dev/null +++ b/cmd/cli/app/ruletype/fixture/applied_rule.rego @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright 2026 The Minder Authors +# SPDX-License-Identifier: Apache-2.0 + +package minder + +# METADATA +# +# title: 'Applied Rule' +# description: 'A minimal test rule type.' +# custom: +# release_phase: alpha +# severity: +# value: low +# def: +# in_entity: repository +# +# # How to gather data (Minimal REST example) +# ingest: +# type: rest +# rest: +# endpoint: '/repos/{{.Entity.Owner}}/{{.Entity.Name}}' +# parse: 'json' +# +# eval: +# rego: +# type: 'deny-by-default' +package minder + +import rego.v1 + +# How to evaluate the data (Minimal Rego example that always passes) +default allow := false + +allow := true \ No newline at end of file diff --git a/cmd/cli/app/ruletype/fixture/rule_type_apply.yaml b/cmd/cli/app/ruletype/fixture/rule_type_apply.yaml index c5a66c2ca9..e11f2f6fdb 100644 --- a/cmd/cli/app/ruletype/fixture/rule_type_apply.yaml +++ b/cmd/cli/app/ruletype/fixture/rule_type_apply.yaml @@ -7,12 +7,14 @@ name: applied_rule context: project: 00000000-0000-0000-0000-000000000000 description: 'A minimal test rule type.' -displayName: 'Applied Rule' -releasePhase: alpha +display_name: 'Applied Rule' +release_phase: alpha severity: value: low def: - inEntity: repository + in_entity: repository + + rule_schema: {} # How to gather data (Minimal REST example) ingest: diff --git a/cmd/cli/app/ruletype/fixture/rule_type_sample.yaml b/cmd/cli/app/ruletype/fixture/rule_type_sample.yaml index f508b7fbe6..26d97d7d96 100644 --- a/cmd/cli/app/ruletype/fixture/rule_type_sample.yaml +++ b/cmd/cli/app/ruletype/fixture/rule_type_sample.yaml @@ -4,10 +4,8 @@ version: v1 type: rule-type name: branch_protection_reviews -context: - project: 00000000-0000-0000-0000-000000000000 description: 'Ensures that branch protection requires at least two approved reviews.' -displayName: 'Require Branch Protection Reviews' +display_name: 'Require Branch Protection Reviews' releasePhase: beta severity: value: high @@ -15,8 +13,8 @@ guidance: | To comply with this rule, you must enable branch protection on your main branch and set the 'Required approving reviews' count to 2 or higher. def: - inEntity: repository - ruleSchema: + in_entity: repository + rule_schema: properties: required_reviews: type: integer diff --git a/cmd/cli/app/ruletype/ruletype_apply.go b/cmd/cli/app/ruletype/ruletype_apply.go index 8f879c9d1e..39fd61b487 100644 --- a/cmd/cli/app/ruletype/ruletype_apply.go +++ b/cmd/cli/app/ruletype/ruletype_apply.go @@ -107,7 +107,7 @@ func applyCommand(cmd *cobra.Command, args []string) error { if f.Path != "-" && shouldSkipFile(f.Path) { continue } - if err = execOnOneRuleType(cmd.Context(), table, f.Path, os.Stdin, project, applyFunc); err != nil { + if err = execOnOneRuleType(cmd, table, f, os.Stdin, project, applyFunc); err != nil { if f.Expanded && minderv1.YouMayHaveTheWrongResource(err) { cmd.PrintErrf("Skipping file %s: not a rule type\n", f.Path) // We'll skip the file if it's not a rule type diff --git a/cmd/cli/app/ruletype/ruletype_apply_test.go b/cmd/cli/app/ruletype/ruletype_apply_test.go index 3b0981b11a..70a6ab57ec 100644 --- a/cmd/cli/app/ruletype/ruletype_apply_test.go +++ b/cmd/cli/app/ruletype/ruletype_apply_test.go @@ -20,6 +20,7 @@ import ( //nolint:paralleltest // Cannot run in parallel because it swaps global Viper/Stdout state func TestApplyCommand(t *testing.T) { applyFixture := filepath.Join("fixture", "rule_type_apply.yaml") + applyRegoFixture := filepath.Join("fixture", "applied_rule.rego") tests := []cli.CmdTestCase{ { @@ -38,6 +39,22 @@ func TestApplyCommand(t *testing.T) { }, GoldenFileName: "apply_create.table", }, + { + Name: "apply - create new rule type with rego input", + Args: []string{"ruletype", "apply", "-f", applyRegoFixture}, + MockSetup: func(t *testing.T, ctrl *gomock.Controller) context.Context { + t.Helper() + client := mockv1.NewMockRuleTypeServiceClient(ctrl) + mockResp := &minderv1.ListRuleTypesResponse{} + cli.LoadFixture(t, "mock_ruletypes_response.json", mockResp) + + client.EXPECT(). + CreateRuleType(gomock.Any(), gomock.Any()). + Return(&minderv1.CreateRuleTypeResponse{RuleType: mockResp.RuleTypes[0]}, nil) + return cli.WithRPCClient[minderv1.RuleTypeServiceClient](context.Background(), client) + }, + GoldenFileName: "apply_create.table", + }, { Name: "apply - update existing rule type via positional arg", Args: []string{"ruletype", "apply", applyFixture}, diff --git a/cmd/cli/app/ruletype/ruletype_create.go b/cmd/cli/app/ruletype/ruletype_create.go index 7fb729938b..6d5a847b9b 100644 --- a/cmd/cli/app/ruletype/ruletype_create.go +++ b/cmd/cli/app/ruletype/ruletype_create.go @@ -77,9 +77,7 @@ func createCommand(cmd *cobra.Command, _ []string) error { if f.Path != "-" && shouldSkipFile(f.Path) { continue } - // cmd.Context() is the root context. We need to create a new context for each file - // so we can avoid the timeout. - if err = execOnOneRuleType(cmd.Context(), table, f.Path, os.Stdin, project, createFunc); err != nil { + if err = execOnOneRuleType(cmd, table, f, os.Stdin, project, createFunc); err != nil { // We swallow errors if you're loading a directory to avoid failing // on test files. if f.Expanded && minderv1.YouMayHaveTheWrongResource(err) { diff --git a/cmd/cli/app/ruletype/ruletype_create_test.go b/cmd/cli/app/ruletype/ruletype_create_test.go index a95d9c2c7c..d87849ed1c 100644 --- a/cmd/cli/app/ruletype/ruletype_create_test.go +++ b/cmd/cli/app/ruletype/ruletype_create_test.go @@ -18,6 +18,7 @@ import ( //nolint:paralleltest // Cannot run in parallel because it swaps global Viper/Stdout state func TestCreateCommand(t *testing.T) { sampleFile := filepath.Join("fixture", "rule_type_sample.yaml") + regoFile := filepath.Join("fixture", "applied_rule.rego") tests := []cli.CmdTestCase{ { @@ -38,6 +39,24 @@ func TestCreateCommand(t *testing.T) { }, GoldenFileName: "create_success.table", }, + { + Name: "create rule type from rego file", + Args: []string{"ruletype", "create", "-f", regoFile}, + MockSetup: func(t *testing.T, ctrl *gomock.Controller) context.Context { + t.Helper() + client := mockv1.NewMockRuleTypeServiceClient(ctrl) + mockResp := &minderv1.ListRuleTypesResponse{} + cli.LoadFixture(t, "mock_ruletypes_response.json", mockResp) + + client.EXPECT(). + CreateRuleType(gomock.Any(), gomock.Any()). + Return(&minderv1.CreateRuleTypeResponse{ + RuleType: mockResp.RuleTypes[0], + }, nil) + return cli.WithRPCClient[minderv1.RuleTypeServiceClient](context.Background(), client) + }, + GoldenFileName: "create_success.table", + }, { Name: "missing required file flag", Args: []string{"ruletype", "create"}, diff --git a/cmd/cli/app/testdata/apply_create.table.golden b/cmd/cli/app/testdata/apply_create.table.golden new file mode 100644 index 0000000000..cf911885ff --- /dev/null +++ b/cmd/cli/app/testdata/apply_create.table.golden @@ -0,0 +1,2 @@ +Skipping expanded file fixture/mock_profiles_response.json due to error resource type not found +Skipping expanded file fixture/mock_ruletypes_response.json due to error resource type not found diff --git a/pkg/fileconvert/collect.go b/pkg/fileconvert/collect.go index d113da487b..c6cf1837c5 100644 --- a/pkg/fileconvert/collect.go +++ b/pkg/fileconvert/collect.go @@ -29,34 +29,51 @@ func ResourcesFromPaths(printer Printer, paths ...string) ([]minderv1.ResourceMe objects := make([]minderv1.ResourceMeta, 0, len(files)) for _, file := range files { - var input Decoder - if file.Path == "-" { - input = yaml.NewDecoder(os.Stdin) - } else { - var closer io.Closer - input, closer = DecoderForFile(file.Path) - if input == nil { - // Not a valid file type, skip it. - continue - } - defer closer.Close() + resources, err := ReadFromPath(printer, file) + if err != nil && !errors.Is(err, minderv1.ErrNotAResource) { + return nil, err } + objects = append(objects, resources...) + + } + return objects, nil +} - for i := 0; ; i = i + 1 { - resource, err := ReadResource(input) - if err != nil { - if errors.Is(err, io.EOF) { - break - } - if file.Expanded && i == 0 { - // Skip files expanded from directories where the contents aren't valid - printer("Skipping expanded file %s due to error %s\n", file.Path, err) - break - } - return nil, fmt.Errorf("error reading resource from file %s: %w", file.Path, err) +// ReadFromPath reads a _single_ file (possibly expanded) and returns a set of Minder +// resources (often 1, but YAML files may include multiple documents) decoded by file +// type. If "-" is passed as the filename, stdin will be read as a YAML document. +// +// TODO: do we want to return minderv1.ErrNotAResource or minderv1.ErrInvalidResource +// on parse erros? Right now, we simply skip them (from ResourcesFromPaths' behavior). +func ReadFromPath(printer Printer, file util.ExpandedFile) ([]minderv1.ResourceMeta, error) { + objects := []minderv1.ResourceMeta{} + var input Decoder + if file.Path == "-" { + input = yaml.NewDecoder(os.Stdin) + } else { + var closer io.Closer + input, closer = DecoderForFile(file.Path) + if input == nil { + // Not a valid file type, skip it. + return objects, nil + } + defer closer.Close() + } + + for i := 0; ; i = i + 1 { + resource, err := ReadResource(input) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + if file.Expanded && i == 0 { + // Skip files expanded from directories where the contents aren't valid + printer("Skipping expanded file %s due to error %s\n", file.Path, err) + break } - objects = append(objects, resource) + return nil, fmt.Errorf("error reading resource from file %s: %w", file.Path, err) } + objects = append(objects, resource) } return objects, nil } diff --git a/pkg/fileconvert/encodedecode.go b/pkg/fileconvert/encodedecode.go index 6aee488030..7e4fcc60ce 100644 --- a/pkg/fileconvert/encodedecode.go +++ b/pkg/fileconvert/encodedecode.go @@ -44,6 +44,8 @@ func DecoderForFile(path string) (Decoder, io.Closer) { builder = func(r io.Reader) Decoder { return json.NewDecoder(r) } case ".yaml", ".yml": builder = func(r io.Reader) Decoder { return yaml.NewDecoder(r) } + case ".rego": + builder = func(r io.Reader) Decoder { return ®oDecoder{filename: path, file: r} } default: return nil, nil } diff --git a/pkg/fileconvert/fileconvert_test.go b/pkg/fileconvert/fileconvert_test.go index 761844aaf1..0ad977fed6 100644 --- a/pkg/fileconvert/fileconvert_test.go +++ b/pkg/fileconvert/fileconvert_test.go @@ -202,7 +202,7 @@ func TestReadAll(t *testing.T) { collectedInput, closer := DecoderForFile("testdata/resources.yaml") require.NotNil(t, collectedInput, "Expected non-nil decoder for profile") t.Cleanup(func() { _ = closer.Close() }) - collected := make([]proto.Message, 0, 3) + collected := make([]proto.Message, 0, 4) for { resource, err := ReadResource(collectedInput) if errors.Is(err, io.EOF) { diff --git a/pkg/fileconvert/rego_parser.go b/pkg/fileconvert/rego_parser.go new file mode 100644 index 0000000000..504cc8bf4b --- /dev/null +++ b/pkg/fileconvert/rego_parser.go @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: Copyright 2023 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +package fileconvert + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "io" + "path/filepath" + "regexp" + + minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" + "gopkg.in/yaml.v3" +) + +type regoDecoder struct { + filename string + file io.Reader +} + +var _ Decoder = (*regoDecoder)(nil) + +// Decode extracts a Minder RuleType from a rego rule file. +// The additional RuleType information is encoded as YAML comments at the +// beginning of the file, following the METADATA format: +// +// https://www.openpolicyagent.org/docs/policy-language#metadata +func (r *regoDecoder) Decode(v any) error { + ruleTypePtr, ok := v.(*map[string]any) + if !ok || ruleTypePtr == nil { + return fmt.Errorf("unexpected type: %T", v) + } + ruleType := map[string]any{} + + var contents bytes.Buffer + n, err := io.Copy(&contents, r.file) + if err != nil { + return err + } + if n == 0 { + return io.EOF + } + + err = extractMetadata(contents.Bytes(), ruleType) + if err != nil { + return err + } + + // The OPA metadata spec says that custom fields should be under the "custom" key + // We also accept them under the top-level object for convenience, despite possible + // future conflicts. + customMap, ok := ruleType["custom"].(map[string]any) + for k, v := range customMap { + ruleType[k] = v + } + + ruleType["type"] = string(minderv1.RuleTypeResource) + ruleType["version"] = "v1" + if _, ok := ruleType["name"]; !ok { + name := filepath.Base(r.filename) + name = name[:len(name)-len(filepath.Ext(name))] + ruleType["name"] = name + } + ruleType["display_name"] = cmp.Or(ruleType["display_name"], ruleType["title"]) + // the "description" key already matches + + defMap, err := ensureEntry(ruleType, "def", map[string]any{}) + if err != nil { + return err + } + + // Rules must have a schema, per validation, but an empty schema is fine. + // For convenience, allow omitting this in the metadata. + ensureEntry(defMap, "rule_schema", map[string]any{}) + + evalMap, err := ensureEntry(defMap, "eval", map[string]any{}) + if err != nil { + return err + } + evalMap["type"] = "rego" + regoMap, err := ensureEntry(evalMap, "rego", map[string]any{}) + if err != nil { + return err + } + regoMap["type"] = cmp.Or(regoMap["type"], "deny-by-default") + // Yes, we have a "def" inside another "def". + regoMap["def"] = contents.String() + + // Atomically assign once there are no errors + *ruleTypePtr = ruleType + + return nil +} + +// metadataExtractor extracts the YAML document +var metadataExtractor = regexp.MustCompile("(?m)^# +METADATA *\r?\n((?:#(?: [^\n]*)?\n)+)") +var removeCommentPrefix = regexp.MustCompile("(?m)^# ") + +// OPA uses YAML metadata inside a specially-headered comment. Extracting +// the metadata requires finding the comment block, then stripping the comment +// prefixes from each line. +func extractMetadata(contents []byte, metadata map[string]any) error { + matches := metadataExtractor.FindSubmatch(contents) + if len(matches) == 0 { + return errors.New("could not find metadata in Rego file") + } + commented := matches[1] + + uncommented := removeCommentPrefix.ReplaceAll(commented, []byte("")) + + return yaml.Unmarshal(uncommented, &metadata) +} + +// ensureEntry simplifies the process of traversing and fetching from JSON-object type maps. +func ensureEntry[T any](in map[string]any, key string, def T) (T, error) { + if _, ok := in[key]; !ok { + in[key] = def + } + if ret, ok := in[key].(T); ok { + return ret, nil + } + return def, fmt.Errorf("unexpected %q tuple: %T", key, in[key]) +} diff --git a/pkg/fileconvert/testdata/directory/ruletype.rego b/pkg/fileconvert/testdata/directory/ruletype.rego new file mode 100644 index 0000000000..788410f84b --- /dev/null +++ b/pkg/fileconvert/testdata/directory/ruletype.rego @@ -0,0 +1,37 @@ +package minder + +# METADATA +# +# title: Test ruletype in Rego format +# description: | +# A longer description of this ruletype +# custom: +# release_phase: alpha +# short_failure_message: This failed +# guidance: | +# You should do better +# def: +# in_entity: pull_request +# ingest: +# type: diff +# diff: +# type: full +# eval: +# data_sources: [{name: ds_a}, {name: ghapi_comments}] +# rego: +# type: constraints + +import rego.v1 + +violations contains {"msg": "a simple violation"} if { + input.creator == "banned" +} + +violations [{"msg": msg}] { + some comment in minder.datasource.ghapi_comments.pr_comment({ + "owner": input.properties["github/repo_owner"], + "repo": input.properties["github/repo_name"], + "pr": input.properties["github/pr_number"], + }) + comment contains "badword" +} diff --git a/pkg/fileconvert/testdata/resources.yaml b/pkg/fileconvert/testdata/resources.yaml index 7277dd45af..5cd6fbaa9c 100644 --- a/pkg/fileconvert/testdata/resources.yaml +++ b/pkg/fileconvert/testdata/resources.yaml @@ -33,6 +33,69 @@ repository: type: profile version: v1 --- +def: + eval: + data_sources: + - name: ds_a + - name: ghapi_comments + rego: + def: | + package minder + + # METADATA + # + # title: Test ruletype in Rego format + # description: | + # A longer description of this ruletype + # custom: + # release_phase: alpha + # short_failure_message: This failed + # guidance: | + # You should do better + # def: + # in_entity: pull_request + # ingest: + # type: diff + # diff: + # type: full + # eval: + # data_sources: [{name: ds_a}, {name: ghapi_comments}] + # rego: + # type: constraints + + import rego.v1 + + violations contains {"msg": "a simple violation"} if { + input.creator == "banned" + } + + violations [{"msg": msg}] { + some comment in minder.datasource.ghapi_comments.pr_comment({ + "owner": input.properties["github/repo_owner"], + "repo": input.properties["github/repo_name"], + "pr": input.properties["github/pr_number"], + }) + comment contains "badword" + } + type: constraints + type: rego + in_entity: pull_request + ingest: + diff: + type: full + type: diff + rule_schema: {} +description: | + A longer description of this ruletype +display_name: Test ruletype in Rego format +guidance: | + You should do better +name: ruletype +release_phase: alpha +short_failure_message: This failed +type: rule-type +version: v1 +--- context: provider: github def: From 5a4932623698173d1292e7acdffb57a4eabda05f Mon Sep 17 00:00:00 2001 From: Evan Anderson Date: Wed, 1 Jul 2026 16:16:37 -0700 Subject: [PATCH 2/5] Add docs for minder project config file formats --- docs/docs/ref/formats/_category_.yml | 2 + docs/docs/ref/formats/datasource.mdx | 58 +++++++++++++++++ docs/docs/ref/formats/profile.mdx | 36 +++++++++++ docs/docs/ref/formats/ruletype.mdx | 95 ++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 docs/docs/ref/formats/_category_.yml create mode 100644 docs/docs/ref/formats/datasource.mdx create mode 100644 docs/docs/ref/formats/profile.mdx create mode 100644 docs/docs/ref/formats/ruletype.mdx diff --git a/docs/docs/ref/formats/_category_.yml b/docs/docs/ref/formats/_category_.yml new file mode 100644 index 0000000000..5d943e18f0 --- /dev/null +++ b/docs/docs/ref/formats/_category_.yml @@ -0,0 +1,2 @@ +label: File Formats +position: 30 \ No newline at end of file diff --git a/docs/docs/ref/formats/datasource.mdx b/docs/docs/ref/formats/datasource.mdx new file mode 100644 index 0000000000..d3008c7ebe --- /dev/null +++ b/docs/docs/ref/formats/datasource.mdx @@ -0,0 +1,58 @@ +--- +title: DataSource +sidebar_position: 60 +--- + +Data Sources define standard schemas for fetching additional information during rule evaluation. [Using data sources during rule evaluation](../../understand/data_sources.md) is covered in the conceptual background. Currently, data sources can only be used with the Rego rule evaluator. + +## YAML Example + +```yaml +version: v1 +type: data-source +name: ghapi +rest: + providerAuth: true + def: + license: + endpoint: https://api.github.com/repos/{owner}/{repo}/license + parse: json + input_schema: + type: object + properties: + owner: + type: string + repo: + type: string + repo_config: + endpoint: https://api.github.com/repos/{owner}/{repo} + parse: json + input_schema: + type: object + properties: + owner: + type: string + repo: + type: string + vulnerability_reporting: + endpoint: https://api.github.com/repos/{owner}/{repo}/private-vulnerability-reporting + parse: json + input_schema: + type: object + properties: + owner: + type: string + repo: + type: string + +``` + +## Fields + +:::note +Note that the Minder CLI uses `snake_case` for field names, but the OpenAPI spec (shown here) uses `camelCase` for names like `display_name`, `in_entity`, etc. +::: + +import ApiSchema from '@theme/ApiSchema'; + +; \ No newline at end of file diff --git a/docs/docs/ref/formats/profile.mdx b/docs/docs/ref/formats/profile.mdx new file mode 100644 index 0000000000..221aa3b89c --- /dev/null +++ b/docs/docs/ref/formats/profile.mdx @@ -0,0 +1,36 @@ +--- +title: Profile +sidebar_position: 40 +--- + +Profiles define policy sets which should be applied to all entities in a project. [Profiles](../../understand/profiles.md) are covered in the conceptual background. Profiles can optionally select which entities they are applied to, and can define parameters for the referenced rule types. + +## YAML Example + +```yaml +--- +version: v1 +type: profile +name: sample-profile +display_name: Sample Profile +alert: "off" +remediate: "off" +repository: + - type: github_branch_protection + def: + min_reviews: 1 + - type: secret_scanning + def: {} + - type: secret_push_protection + def: {} +``` + +## Fields + +:::note +Note that the Minder CLI uses `snake_case` for field names, but the OpenAPI spec (shown here) uses `camelCase` for names like `display_name`, `in_entity`, etc. +::: + +import ApiSchema from '@theme/ApiSchema'; + +; \ No newline at end of file diff --git a/docs/docs/ref/formats/ruletype.mdx b/docs/docs/ref/formats/ruletype.mdx new file mode 100644 index 0000000000..0e9cb6fdd6 --- /dev/null +++ b/docs/docs/ref/formats/ruletype.mdx @@ -0,0 +1,95 @@ +--- +title: RuleType +sidebar_position: 20 +--- + +Rule types define an evaluation (and remediation) process for an individual policy check. Rule types are [evaluated](../../understand/rule_evaluation.md) as part of a profile, via multiple phases of evaluation: + + 1. **Ingest** data about the entity. + 2. **Evaluate** state using the specified rule definition. + 3. **Store** output from the rule evaluation. + 4. **Remediate** failed evaluations, if enabled. + 5. **Alert** the user about failed evaluations, if enabled. + +Rule types can be defined using YAML syntax. Additionally, rules which use the [Rego evaluator](../../how-to/writing-rules-in-rego.md) can be written using Rego syntax with the additional rule type fields recorded as [Rego package metadata](https://www.openpolicyagent.org/docs/policy-language#metadata). + +## YAML Example + +```yaml +version: v1 +type: rule_type +name: github_branch_protection +display_name: GitHub branch protection +description: Ensure protected branches are enabled. +guidance: Enable branch protection on default branch. +severity: + value: high +def: + in_entity: repository + rule_schema: {} + ingest: + type: rest + rest: + endpoint: '/repos/{{.Entity.Owner}}/{{.Entity.Name}}/branches/{{.Entity.DefaultBranch}}/protection' + parse: json + eval: + type: rego + rego: + type: deny-by-default + def: | + package minder + + input.ingested.required_pull_request_reviews.required_approving_review_count >= 1 + input.ingested.enforce_admins.enabled == true + input.ingested.allow_force_pushes == false + input.ingested.allow_deletions == false +``` + +## Rego Example + +The Minder CLI will parse rego files and extract RuleType metadata from them. It will automatically fill certain fields (such as `type` and `version`), and will promote the following fields: + +| Rego field | RuleType field | +| --- | --- | +| `name` | populate from filename if not present | +| `display_name` | populate from `title` if not present | +| `def.rule_schemad` | defaults to empty-object | + +```rego +package minder + +# METADATA +# +# name: github_branch_protection +# title: GitHub branch protection +# description: Ensure protected branches are enabled. +# custom: +# guidance: Enable branch protection on default branch. +# severity: +# value: high +# def: +# in_entity: repository +# ingest: +# type: rest +# rest: +# endpoint: '/repos/{{.Entity.Owner}}/{{.Entity.Name}}/branches/{{.Entity.DefaultBranch}}/protection' +# parse: json +# eval: +# rego: +# type: deny-by-default + +input.ingested.required_pull_request_reviews.required_approving_review_count >= 1 +input.ingested.enforce_admins.enabled == true +input.ingested.allow_force_pushes == false +input.ingested.allow_deletions == false +``` + +## Fields + +:::note +Note that the Minder CLI uses `snake_case` for field names, but the OpenAPI spec (shown here) uses `camelCase` for names like `display_name`, `in_entity`, etc. +::: + +import ApiSchema from '@theme/ApiSchema'; + +; \ No newline at end of file From 227667cae242886cb8aef452ca5df53aa17bfea8 Mon Sep 17 00:00:00 2001 From: Evan Anderson Date: Wed, 1 Jul 2026 17:09:21 -0700 Subject: [PATCH 3/5] Fix lint errors --- cmd/cli/app/apply.go | 4 +++- cmd/cli/app/fixture/empty_file.yaml | 4 ++++ cmd/cli/app/ruletype/common.go | 1 - cmd/cli/app/ruletype/ruletype_apply.go | 3 +-- cmd/cli/app/ruletype/ruletype_create.go | 2 +- pkg/fileconvert/rego_parser.go | 12 +++++++----- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/cmd/cli/app/apply.go b/cmd/cli/app/apply.go index b3dbdfe427..9d7c668e67 100644 --- a/cmd/cli/app/apply.go +++ b/cmd/cli/app/apply.go @@ -32,7 +32,9 @@ var applyCmd = &cobra.Command{ } // applyCommand is the general-purpose "apply" subcommand -func applyCommand(cmd *cobra.Command, args []string) error { //ctx context.Context, cmd *cobra.Command, args []string, conn *grpc.ClientConn) error { +// +//nolint:gocyclo +func applyCommand(cmd *cobra.Command, args []string) error { // Step 1: Collect inputs, by reading files or directories. Use the "-f" flag if set, positional arguments otherwise. fileNames := args argFiles, _ := cmd.Flags().GetStringSlice("file") diff --git a/cmd/cli/app/fixture/empty_file.yaml b/cmd/cli/app/fixture/empty_file.yaml index e69de29bb2..68e52e7971 100644 --- a/cmd/cli/app/fixture/empty_file.yaml +++ b/cmd/cli/app/fixture/empty_file.yaml @@ -0,0 +1,4 @@ +# Copyright 2026 The Minder Authors +# SPDX-License-Identifier: Apache-2.0 + +# (boilerplate required by presubmit checks) \ No newline at end of file diff --git a/cmd/cli/app/ruletype/common.go b/cmd/cli/app/ruletype/common.go index df5fe7e51e..0e0d82a8ad 100644 --- a/cmd/cli/app/ruletype/common.go +++ b/cmd/cli/app/ruletype/common.go @@ -27,7 +27,6 @@ func execOnOneRuleType( cmd *cobra.Command, t table.Table, f util.ExpandedFile, - dashOpen io.Reader, proj string, exec func(context.Context, string, *minderv1.RuleType) (*minderv1.RuleType, error), ) error { diff --git a/cmd/cli/app/ruletype/ruletype_apply.go b/cmd/cli/app/ruletype/ruletype_apply.go index f959d16de4..12692be8cd 100644 --- a/cmd/cli/app/ruletype/ruletype_apply.go +++ b/cmd/cli/app/ruletype/ruletype_apply.go @@ -6,7 +6,6 @@ package ruletype import ( "context" "fmt" - "os" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -109,7 +108,7 @@ func applyCommand(cmd *cobra.Command, args []string) error { if f.Path != "-" && shouldSkipFile(f.Path) { continue } - if err = execOnOneRuleType(cmd, table, f, os.Stdin, project, applyFunc); err != nil { + if err = execOnOneRuleType(cmd, table, f, project, applyFunc); err != nil { if f.Expanded && minderv1.YouMayHaveTheWrongResource(err) { cmd.PrintErrf("Skipping file %s: not a rule type\n", f.Path) // We'll skip the file if it's not a rule type diff --git a/cmd/cli/app/ruletype/ruletype_create.go b/cmd/cli/app/ruletype/ruletype_create.go index 72fe8714f3..9cf620e8d4 100644 --- a/cmd/cli/app/ruletype/ruletype_create.go +++ b/cmd/cli/app/ruletype/ruletype_create.go @@ -78,7 +78,7 @@ func createCommand(cmd *cobra.Command, _ []string) error { if f.Path != "-" && shouldSkipFile(f.Path) { continue } - if err = execOnOneRuleType(cmd, table, f, os.Stdin, project, createFunc); err != nil { + if err = execOnOneRuleType(cmd, table, f, project, createFunc); err != nil { // We swallow errors if you're loading a directory to avoid failing // on test files. if f.Expanded && minderv1.YouMayHaveTheWrongResource(err) { diff --git a/pkg/fileconvert/rego_parser.go b/pkg/fileconvert/rego_parser.go index 504cc8bf4b..de5adc27fe 100644 --- a/pkg/fileconvert/rego_parser.go +++ b/pkg/fileconvert/rego_parser.go @@ -12,8 +12,9 @@ import ( "path/filepath" "regexp" - minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" "gopkg.in/yaml.v3" + + minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" ) type regoDecoder struct { @@ -52,9 +53,10 @@ func (r *regoDecoder) Decode(v any) error { // The OPA metadata spec says that custom fields should be under the "custom" key // We also accept them under the top-level object for convenience, despite possible // future conflicts. - customMap, ok := ruleType["custom"].(map[string]any) - for k, v := range customMap { - ruleType[k] = v + if customMap, ok := ruleType["custom"].(map[string]any); ok { + for k, v := range customMap { + ruleType[k] = v + } } ruleType["type"] = string(minderv1.RuleTypeResource) @@ -74,7 +76,7 @@ func (r *regoDecoder) Decode(v any) error { // Rules must have a schema, per validation, but an empty schema is fine. // For convenience, allow omitting this in the metadata. - ensureEntry(defMap, "rule_schema", map[string]any{}) + _, _ = ensureEntry(defMap, "rule_schema", map[string]any{}) evalMap, err := ensureEntry(defMap, "eval", map[string]any{}) if err != nil { From bdeddc12fbf2973f32e8d516d56c295174baa81d Mon Sep 17 00:00:00 2001 From: Evan Anderson Date: Thu, 2 Jul 2026 15:22:05 -0700 Subject: [PATCH 4/5] Update rego to pass opa fmt and opa check --- cmd/cli/app/fixture/applied_rule.rego | 6 ++---- .../app/ruletype/fixture/applied_rule.rego | 6 ++---- docs/docs/ref/formats/ruletype.mdx | 5 +++-- .../testdata/directory/ruletype.rego | 21 ++++++++++--------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/cmd/cli/app/fixture/applied_rule.rego b/cmd/cli/app/fixture/applied_rule.rego index aab0d90ab2..91d76f9b1f 100644 --- a/cmd/cli/app/fixture/applied_rule.rego +++ b/cmd/cli/app/fixture/applied_rule.rego @@ -1,10 +1,8 @@ # SPDX-FileCopyrightText: Copyright 2026 The Minder Authors # SPDX-License-Identifier: Apache-2.0 -package minder - # METADATA -# +# # title: 'Applied Rule' # description: 'A minimal test rule type.' # custom: @@ -32,4 +30,4 @@ import rego.v1 # How to evaluate the data (Minimal Rego example that always passes) default allow := false -allow := true \ No newline at end of file +allow := true diff --git a/cmd/cli/app/ruletype/fixture/applied_rule.rego b/cmd/cli/app/ruletype/fixture/applied_rule.rego index 88eab00a6b..d8d80ab179 100644 --- a/cmd/cli/app/ruletype/fixture/applied_rule.rego +++ b/cmd/cli/app/ruletype/fixture/applied_rule.rego @@ -1,10 +1,8 @@ # SPDX-FileCopyrightText: Copyright 2026 The Minder Authors # SPDX-License-Identifier: Apache-2.0 -package minder - # METADATA -# +# # title: 'Applied Rule' # description: 'A minimal test rule type.' # custom: @@ -31,4 +29,4 @@ import rego.v1 # How to evaluate the data (Minimal Rego example that always passes) default allow := false -allow := true \ No newline at end of file +allow := true diff --git a/docs/docs/ref/formats/ruletype.mdx b/docs/docs/ref/formats/ruletype.mdx index 0e9cb6fdd6..92776ef140 100644 --- a/docs/docs/ref/formats/ruletype.mdx +++ b/docs/docs/ref/formats/ruletype.mdx @@ -56,8 +56,6 @@ The Minder CLI will parse rego files and extract RuleType metadata from them. I | `def.rule_schemad` | defaults to empty-object | ```rego -package minder - # METADATA # # name: github_branch_protection @@ -77,6 +75,9 @@ package minder # eval: # rego: # type: deny-by-default +package minder + +import rego.v1 input.ingested.required_pull_request_reviews.required_approving_review_count >= 1 input.ingested.enforce_admins.enabled == true diff --git a/pkg/fileconvert/testdata/directory/ruletype.rego b/pkg/fileconvert/testdata/directory/ruletype.rego index 788410f84b..cdb5904206 100644 --- a/pkg/fileconvert/testdata/directory/ruletype.rego +++ b/pkg/fileconvert/testdata/directory/ruletype.rego @@ -1,5 +1,3 @@ -package minder - # METADATA # # title: Test ruletype in Rego format @@ -20,18 +18,21 @@ package minder # data_sources: [{name: ds_a}, {name: ghapi_comments}] # rego: # type: constraints +package minder import rego.v1 violations contains {"msg": "a simple violation"} if { - input.creator == "banned" + input.creator == "banned" } -violations [{"msg": msg}] { - some comment in minder.datasource.ghapi_comments.pr_comment({ - "owner": input.properties["github/repo_owner"], - "repo": input.properties["github/repo_name"], - "pr": input.properties["github/pr_number"], - }) - comment contains "badword" +violations contains {"msg": msg} if { + some comment in minder.datasource.ghapi_comments.pr_comment({ + "owner": input.properties["github/repo_owner"], + "repo": input.properties["github/repo_name"], + "pr": input.properties["github/pr_number"], + }) + contains(comment.body, "badword") + + msg = sprintf("Comment %d is naughty", [comment.id]) } From b2f0f3d4c43c5f3232ea2d2f4b56b89fb52bb73c Mon Sep 17 00:00:00 2001 From: Evan Anderson Date: Thu, 2 Jul 2026 15:33:36 -0700 Subject: [PATCH 5/5] Error on zero ruletypes in apply/create --- cmd/cli/app/ruletype/common.go | 3 +++ cmd/cli/app/ruletype/fixture/empty_file.yaml | 2 ++ cmd/cli/app/ruletype/ruletype_apply_test.go | 13 ++++++++++++ pkg/fileconvert/testdata/resources.yaml | 21 ++++++++++---------- 4 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 cmd/cli/app/ruletype/fixture/empty_file.yaml diff --git a/cmd/cli/app/ruletype/common.go b/cmd/cli/app/ruletype/common.go index 0e0d82a8ad..bd24705045 100644 --- a/cmd/cli/app/ruletype/common.go +++ b/cmd/cli/app/ruletype/common.go @@ -34,6 +34,9 @@ func execOnOneRuleType( if err != nil { return err } + if len(resources) == 0 { + return fmt.Errorf("%s did not contain a ruletype", f.Path) + } for _, resource := range resources { r, ok := resource.(*minderv1.RuleType) diff --git a/cmd/cli/app/ruletype/fixture/empty_file.yaml b/cmd/cli/app/ruletype/fixture/empty_file.yaml new file mode 100644 index 0000000000..0184207a1a --- /dev/null +++ b/cmd/cli/app/ruletype/fixture/empty_file.yaml @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright 2023 The Minder Authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/cmd/cli/app/ruletype/ruletype_apply_test.go b/cmd/cli/app/ruletype/ruletype_apply_test.go index 6586b92a6e..3b5ed8450d 100644 --- a/cmd/cli/app/ruletype/ruletype_apply_test.go +++ b/cmd/cli/app/ruletype/ruletype_apply_test.go @@ -21,6 +21,7 @@ import ( func TestApplyCommand(t *testing.T) { applyFixture := filepath.Join("fixture", "rule_type_apply.yaml") applyRegoFixture := filepath.Join("fixture", "applied_rule.rego") + emptyFixture := filepath.Join("fixture", "empty_file.yaml") tests := []cli.CmdTestCase{ { @@ -98,6 +99,18 @@ func TestApplyCommand(t *testing.T) { }, GoldenFileName: "apply_warning.table", }, + { + Name: "apply with empty file fails", + Args: []string{"ruletype", "apply", "-f", emptyFixture}, + MockSetup: func(t *testing.T, ctrl *gomock.Controller) context.Context { + // Apply creates the client _before_ loading the file, so we need to mock out + // the client even though we don't call the service. + t.Helper() + client := mockv1.NewMockRuleTypeServiceClient(ctrl) + return cli.WithRPCClient[minderv1.RuleTypeServiceClient](context.Background(), client) + }, + ExpectedError: "fixture/empty_file.yaml did not contain a ruletype", + }, { Name: "no files specified", Args: []string{"ruletype", "apply"}, diff --git a/pkg/fileconvert/testdata/resources.yaml b/pkg/fileconvert/testdata/resources.yaml index 5cd6fbaa9c..9f305cd73c 100644 --- a/pkg/fileconvert/testdata/resources.yaml +++ b/pkg/fileconvert/testdata/resources.yaml @@ -40,8 +40,6 @@ def: - name: ghapi_comments rego: def: | - package minder - # METADATA # # title: Test ruletype in Rego format @@ -62,20 +60,23 @@ def: # data_sources: [{name: ds_a}, {name: ghapi_comments}] # rego: # type: constraints + package minder import rego.v1 violations contains {"msg": "a simple violation"} if { - input.creator == "banned" + input.creator == "banned" } - violations [{"msg": msg}] { - some comment in minder.datasource.ghapi_comments.pr_comment({ - "owner": input.properties["github/repo_owner"], - "repo": input.properties["github/repo_name"], - "pr": input.properties["github/pr_number"], - }) - comment contains "badword" + violations contains {"msg": msg} if { + some comment in minder.datasource.ghapi_comments.pr_comment({ + "owner": input.properties["github/repo_owner"], + "repo": input.properties["github/repo_name"], + "pr": input.properties["github/pr_number"], + }) + contains(comment.body, "badword") + + msg = sprintf("Comment %d is naughty", [comment.id]) } type: constraints type: rego