diff --git a/cmd/cli/app/apply.go b/cmd/cli/app/apply.go
index a4287705c7..9d7c668e67 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,39 @@ 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 {
+//
+//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
- 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 +84,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 +123,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..91d76f9b1f
--- /dev/null
+++ b/cmd/cli/app/fixture/applied_rule.rego
@@ -0,0 +1,33 @@
+# SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
+# SPDX-License-Identifier: Apache-2.0
+
+# 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
diff --git a/cmd/cli/app/fixture/empty_file.yaml b/cmd/cli/app/fixture/empty_file.yaml
new file mode 100644
index 0000000000..68e52e7971
--- /dev/null
+++ 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/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 8570475af1..bd24705045 100644
--- a/cmd/cli/app/ruletype/common.go
+++ b/cmd/cli/app/ruletype/common.go
@@ -20,51 +20,54 @@ 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"
)
func execOnOneRuleType(
- ctx context.Context,
+ cmd *cobra.Command,
t table.Table,
- f string,
- dashOpen io.Reader,
+ f util.ExpandedFile,
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)
+ return err
}
- defer closer()
-
- r := &minderv1.RuleType{}
- if err := minderv1.ParseResource(reader, r); err != nil {
- return fmt.Errorf("error parsing rule type: %w", err)
+ if len(resources) == 0 {
+ return fmt.Errorf("%s did not contain a ruletype", f.Path)
}
- // 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
}
@@ -74,7 +77,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 {
@@ -95,10 +98,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..d8d80ab179
--- /dev/null
+++ b/cmd/cli/app/ruletype/fixture/applied_rule.rego
@@ -0,0 +1,32 @@
+# SPDX-FileCopyrightText: Copyright 2026 The Minder Authors
+# SPDX-License-Identifier: Apache-2.0
+
+# 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
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/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 69ea9e95ba..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.Context(), table, f.Path, 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_apply_test.go b/cmd/cli/app/ruletype/ruletype_apply_test.go
index 28e9695e36..3b5ed8450d 100644
--- a/cmd/cli/app/ruletype/ruletype_apply_test.go
+++ b/cmd/cli/app/ruletype/ruletype_apply_test.go
@@ -20,6 +20,8 @@ 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")
+ emptyFixture := filepath.Join("fixture", "empty_file.yaml")
tests := []cli.CmdTestCase{
{
@@ -38,6 +40,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},
@@ -81,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/cmd/cli/app/ruletype/ruletype_create.go b/cmd/cli/app/ruletype/ruletype_create.go
index 78f37c6ffc..9cf620e8d4 100644
--- a/cmd/cli/app/ruletype/ruletype_create.go
+++ b/cmd/cli/app/ruletype/ruletype_create.go
@@ -78,9 +78,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, 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 6627e68c1a..f7ef42fe93 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{
{
@@ -57,6 +58,25 @@ func TestCreateCommand(t *testing.T) {
},
GoldenFileName: "create_warning.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/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..92776ef140
--- /dev/null
+++ b/docs/docs/ref/formats/ruletype.mdx
@@ -0,0 +1,96 @@
+---
+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
+# 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
+package minder
+
+import rego.v1
+
+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
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..de5adc27fe
--- /dev/null
+++ b/pkg/fileconvert/rego_parser.go
@@ -0,0 +1,128 @@
+// SPDX-FileCopyrightText: Copyright 2023 The Minder Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package fileconvert
+
+import (
+ "bytes"
+ "cmp"
+ "errors"
+ "fmt"
+ "io"
+ "path/filepath"
+ "regexp"
+
+ "gopkg.in/yaml.v3"
+
+ minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
+)
+
+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.
+ if customMap, ok := ruleType["custom"].(map[string]any); ok {
+ 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..cdb5904206
--- /dev/null
+++ b/pkg/fileconvert/testdata/directory/ruletype.rego
@@ -0,0 +1,38 @@
+# 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
+package minder
+
+import rego.v1
+
+violations contains {"msg": "a simple violation"} if {
+ input.creator == "banned"
+}
+
+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])
+}
diff --git a/pkg/fileconvert/testdata/resources.yaml b/pkg/fileconvert/testdata/resources.yaml
index 7277dd45af..9f305cd73c 100644
--- a/pkg/fileconvert/testdata/resources.yaml
+++ b/pkg/fileconvert/testdata/resources.yaml
@@ -33,6 +33,70 @@ repository:
type: profile
version: v1
---
+def:
+ eval:
+ data_sources:
+ - name: ds_a
+ - name: ghapi_comments
+ rego:
+ def: |
+ # 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
+ package minder
+
+ import rego.v1
+
+ violations contains {"msg": "a simple violation"} if {
+ input.creator == "banned"
+ }
+
+ 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
+ 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: