diff --git a/app/cli/cmd/policy.go b/app/cli/cmd/policy.go new file mode 100644 index 000000000..db34a4425 --- /dev/null +++ b/app/cli/cmd/policy.go @@ -0,0 +1,30 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newPolicyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "policy", + Short: "Craft chainloop policies", + } + + cmd.AddCommand(newPolicyDevelopCmd()) + return cmd +} diff --git a/app/cli/cmd/policy_develop.go b/app/cli/cmd/policy_develop.go new file mode 100644 index 000000000..1d38ce88c --- /dev/null +++ b/app/cli/cmd/policy_develop.go @@ -0,0 +1,33 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newPolicyDevelopCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "develop", + Aliases: []string{"devel"}, + Short: `Tools for policy development +Refer to https://docs.chainloop.dev/guides/custom-policies +`, + } + + cmd.AddCommand(newPolicyDevelopInitCmd()) + return cmd +} diff --git a/app/cli/cmd/policy_develop_init.go b/app/cli/cmd/policy_develop_init.go new file mode 100644 index 000000000..03d6e826f --- /dev/null +++ b/app/cli/cmd/policy_develop_init.go @@ -0,0 +1,78 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + + "github.com/chainloop-dev/chainloop/app/cli/internal/action" + "github.com/spf13/cobra" +) + +func newPolicyDevelopInitCmd() *cobra.Command { + var ( + force bool + embedded bool + name string + description string + directory string + ) + + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize a new policy", + Long: `Initialize a new policy by creating template policy files in the specified directory. +By default, it creates chainloop-policy.yaml and chainloop-policy.rego files.`, + Example: ` + # Initialize in current directory with separate files + chainloop policy develop init + + # Initialize in specific directory with embedded format and policy name + chainloop policy develop init --directory ./policies --embedded --name mypolicy`, + RunE: func(_ *cobra.Command, _ []string) error { + if directory == "" { + directory = "." + } + opts := &action.PolicyInitOpts{ + Force: force, + Embedded: embedded, + Name: name, + Description: description, + Directory: directory, + } + + policyInit, err := action.NewPolicyInit(opts, actionOpts) + if err != nil { + return fmt.Errorf("failed to initialize policy: %w", err) + } + + if err := policyInit.Run(); err != nil { + return newGracefulError(err) + } + + logger.Info().Msg("Initialized policy files") + + return nil + }, + } + + cmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing files") + cmd.Flags().BoolVar(&embedded, "embedded", false, "initialize an embedded policy (single YAML file)") + cmd.Flags().StringVar(&name, "name", "", "name of the policy") + cmd.Flags().StringVar(&description, "description", "", "description of the policy") + cmd.Flags().StringVar(&directory, "directory", "", "directory for policy") + return cmd +} diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 7e2e2422a..558f8477c 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -242,7 +242,7 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command { rootCmd.AddCommand(newWorkflowCmd(), newAuthCmd(), NewVersionCmd(), newAttestationCmd(), newArtifactCmd(), newConfigCmd(), newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(), - newReferrerDiscoverCmd(), + newReferrerDiscoverCmd(), newPolicyCmd(), ) // Load plugins if we are not running a subcommand diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 71da46184..be11a0d0d 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -2700,6 +2700,180 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` +## chainloop policy + +Craft chainloop policies + +Options + +``` +-h, --help help for policy +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop policy develop + +Tools for policy development +Refer to https://docs.chainloop.dev/guides/custom-policies + +Options + +``` +-h, --help help for develop +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +#### chainloop policy develop help + +Help about any command + +Synopsis + +Help provides help for any command in the application. +Simply type develop help [path to command] for full details. + +``` +chainloop policy develop help [command] [flags] +``` + +Options + +``` +-h, --help help for help +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +#### chainloop policy develop init + +Initialize a new policy + +Synopsis + +Initialize a new policy by creating template policy files in the specified directory. +By default, it creates chainloop-policy.yaml and chainloop-policy.rego files. + +``` +chainloop policy develop init [flags] +``` + +Examples + +``` + +Initialize in current directory with separate files +chainloop policy develop init + +Initialize in specific directory with embedded format and policy name +chainloop policy develop init --directory ./policies --embedded --name mypolicy +``` + +Options + +``` +--description string description of the policy +--directory string directory for policy +--embedded initialize an embedded policy (single YAML file) +-f, --force overwrite existing files +-h, --help help for init +--name string name of the policy +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop policy help + +Help about any command + +Synopsis + +Help provides help for any command in the application. +Simply type policy help [path to command] for full details. + +``` +chainloop policy help [command] [flags] +``` + +Options + +``` +-h, --help help for help +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + ## chainloop version Command line version diff --git a/app/cli/internal/action/policy_develop_init.go b/app/cli/internal/action/policy_develop_init.go new file mode 100644 index 000000000..735d3efc8 --- /dev/null +++ b/app/cli/internal/action/policy_develop_init.go @@ -0,0 +1,58 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "fmt" + + "github.com/chainloop-dev/chainloop/app/cli/internal/policydevel" +) + +type PolicyInitOpts struct { + Force bool + Embedded bool + Name string + Description string + Directory string +} + +type PolicyInit struct { + *ActionsOpts + opts *PolicyInitOpts +} + +func NewPolicyInit(opts *PolicyInitOpts, actionOpts *ActionsOpts) (*PolicyInit, error) { + return &PolicyInit{ + ActionsOpts: actionOpts, + opts: opts, + }, nil +} + +func (action *PolicyInit) Run() error { + initOpts := &policydevel.InitOptions{ + Directory: action.opts.Directory, + Embedded: action.opts.Embedded, + Force: action.opts.Force, + Name: action.opts.Name, + Description: action.opts.Description, + } + + if err := policydevel.Initialize(initOpts); err != nil { + return fmt.Errorf("initializing policy: %w", err) + } + + return nil +} diff --git a/app/cli/internal/policydevel/init.go b/app/cli/internal/policydevel/init.go new file mode 100644 index 000000000..c8a50cf91 --- /dev/null +++ b/app/cli/internal/policydevel/init.go @@ -0,0 +1,181 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policydevel + +import ( + "bytes" + "embed" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +//go:embed templates/* +var templateFS embed.FS + +const ( + policyTemplateRegoPath = "templates/example-policy.rego" + policyTemplatePath = "templates/example-policy.yaml" + defaultPolicyName = "chainloop-policy" + defaultPolicyDescription = "Chainloop validation policy" + defaultMaterialKind = "SBOM_CYCLONEDX_JSON" +) + +type TemplateData struct { + Name string + Description string + RegoPath string + RegoContent string + Embedded bool + MaterialKind string +} + +type Content struct { + YAML string + Rego string +} + +type InitOptions struct { + Directory string + Embedded bool + Force bool + Name string + Description string +} + +func Initialize(opts *InitOptions) error { + content, err := loadAndProcessTemplates(opts) + if err != nil { + return fmt.Errorf("failed to process templates: %w", err) + } + + files := make(map[string]string) + fileNameBase := sanitizeName(getPolicyName(opts.Name)) + + if opts.Embedded { + files[fileNameBase+".yaml"] = content.YAML + } else { + files[fileNameBase+".yaml"] = content.YAML + files[fileNameBase+".rego"] = content.Rego + } + + return writeFiles(opts.Directory, files, opts.Force) +} + +func getPolicyName(name string) string { + if name == "" { + return defaultPolicyName + } + return name +} + +func getPolicyDescription(description string) string { + if description == "" { + return defaultPolicyDescription + } + return description +} + +func loadAndProcessTemplates(opts *InitOptions) (*Content, error) { + regoContent, err := templateFS.ReadFile(policyTemplateRegoPath) + if err != nil { + return nil, fmt.Errorf("failed to read Rego template: %w", err) + } + + data := &TemplateData{ + Name: getPolicyName(opts.Name), + Description: getPolicyDescription(opts.Description), + RegoPath: sanitizeName(getPolicyName(opts.Name)) + ".rego", + RegoContent: string(regoContent), + Embedded: opts.Embedded, + MaterialKind: defaultMaterialKind, + } + + // Process main template + content, err := templateFS.ReadFile(policyTemplatePath) + if err != nil { + return nil, fmt.Errorf("failed to read policy template: %w", err) + } + + yamlContent, err := executeTemplate(string(content), data) + if err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + // For non-embedded case, we still need the Rego content to write to file + if !opts.Embedded { + return &Content{ + YAML: yamlContent, + Rego: data.RegoContent, + }, nil + } + + return &Content{YAML: yamlContent}, nil +} + +// Add custom template functions +func executeTemplate(content string, data *TemplateData) (string, error) { + tmpl := template.New("policy").Funcs(template.FuncMap{ + "sanitize": sanitizeName, + "trimSpace": strings.TrimSpace, + "indent": func(spaces int, s string) string { + pad := strings.Repeat(" ", spaces) + return pad + strings.ReplaceAll(s, "\n", "\n"+pad) + }, + }) + + tmpl, err := tmpl.Parse(content) + if err != nil { + return "", fmt.Errorf("template parsing error: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("template execution error: %w", err) + } + + return buf.String(), nil +} + +func sanitizeName(name string) string { + return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), " ", "-")) +} + +func writeFiles(dir string, files map[string]string, force bool) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + for filename, content := range files { + path := filepath.Join(dir, filename) + if !force && fileExists(path) { + return fmt.Errorf("file %s already exists (use --force to overwrite)", path) + } + + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + return fmt.Errorf("failed to write file %s: %w", filename, err) + } + } + + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return !os.IsNotExist(err) +} diff --git a/app/cli/internal/policydevel/init_test.go b/app/cli/internal/policydevel/init_test.go new file mode 100644 index 000000000..ddd537383 --- /dev/null +++ b/app/cli/internal/policydevel/init_test.go @@ -0,0 +1,258 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policydevel + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitialize(t *testing.T) { + tempDir := t.TempDir() + + t.Run("embedded rego", func(t *testing.T) { + opts := &InitOptions{ + Directory: tempDir, + Embedded: true, + Name: "test-policy", + Description: "test description", + } + + err := Initialize(opts) + require.NoError(t, err) + + policyPath := filepath.Join(tempDir, "test-policy.yaml") + assert.FileExists(t, policyPath) + }) + + t.Run("standalone rego file", func(t *testing.T) { + opts := &InitOptions{ + Directory: tempDir, + Embedded: false, + Name: "standalone-rego", + } + + err := Initialize(opts) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(tempDir, "standalone-rego.yaml")) + assert.FileExists(t, filepath.Join(tempDir, "standalone-rego.rego")) + }) + + t.Run("file exists and no force", func(t *testing.T) { + opts := &InitOptions{ + Directory: tempDir, + Name: "duplicate", + } + + // First time should succeed + err := Initialize(opts) + require.NoError(t, err) + + // Second time should fail + err = Initialize(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + // With force it should succeed + opts.Force = true + err = Initialize(opts) + require.NoError(t, err) + }) + + t.Run("name and description are properly set", func(t *testing.T) { + customName := "custom-policy-name" + customDesc := "This is a custom policy description" + + opts := &InitOptions{ + Directory: tempDir, + Name: customName, + Description: customDesc, + Embedded: true, + } + + err := Initialize(opts) + require.NoError(t, err) + + policyPath := filepath.Join(tempDir, customName+".yaml") + assert.FileExists(t, policyPath) + + content, err := os.ReadFile(policyPath) + require.NoError(t, err) + + policyContent := string(content) + + assert.Contains(t, policyContent, "name: "+customName) + + assert.Contains(t, policyContent, "description: "+customDesc) + + assert.FileExists(t, filepath.Join(tempDir, customName+".yaml")) + }) +} + +func TestLoadAndProcessTemplates(t *testing.T) { + t.Run("embedded rego", func(t *testing.T) { + opts := &InitOptions{ + Embedded: true, + Name: "embedded-test", + } + + content, err := loadAndProcessTemplates(opts) + require.NoError(t, err) + assert.NotEmpty(t, content.YAML) + assert.Empty(t, content.Rego) // Rego file should be empty for embedded + }) + + t.Run("separate rego file", func(t *testing.T) { + opts := &InitOptions{ + Embedded: false, + Name: "separate-rego-test", + } + + content, err := loadAndProcessTemplates(opts) + require.NoError(t, err) + assert.NotEmpty(t, content.YAML) + assert.NotEmpty(t, content.Rego) + }) +} + +func TestExecuteTemplate(t *testing.T) { + testCases := []struct { + name string + template string + data *TemplateData + expected string + }{ + { + name: "basic interpolation", + template: "Hello {{.Name}}!", + data: &TemplateData{Name: "world"}, + expected: "Hello world!", + }, + { + name: "sanitize function", + template: "{{.Name | sanitize}}", + data: &TemplateData{Name: "My Policy"}, + expected: "my-policy", + }, + { + name: "indent function", + template: "{{indent 2 \"hello\"}}", + expected: " hello", + }, + { + name: "multiple fields interpolation", + template: "Name: {{.Name}}, Desc: {{.Description}}", + data: &TemplateData{Name: "test", Description: "description"}, + expected: "Name: test, Desc: description", + }, + { + name: "trimSpace function", + template: "{{.Name | trimSpace}}", + data: &TemplateData{Name: " spaced "}, + expected: "spaced", + }, + { + name: "combined functions", + template: "{{.Name | trimSpace | sanitize}}", + data: &TemplateData{Name: " My Policy 123 "}, + expected: "my-policy-123", + }, + { + name: "empty template", + template: "", + data: &TemplateData{Name: "test"}, + expected: "", + }, + { + name: "embedded rego flag", + template: "Embedded: {{.Embedded}}", + data: &TemplateData{Embedded: true}, + expected: "Embedded: true", + }, + { + name: "material kind", + template: "Material: {{.MaterialKind}}", + data: &TemplateData{MaterialKind: "SBOM_CYCLONEDX_JSON"}, + expected: "Material: SBOM_CYCLONEDX_JSON", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := executeTemplate(tc.template, tc.data) + require.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } + + errorCases := []struct { + name string + template string + data *TemplateData + errMsg string + }{ + { + name: "invalid template syntax", + template: "{{.Name", + data: &TemplateData{Name: "test"}, + errMsg: "template parsing error", + }, + { + name: "missing field", + template: "{{.MissingField}}", + data: &TemplateData{Name: "test"}, + errMsg: "template execution error", + }, + { + name: "invalid function", + template: "{{.Name | invalidFunc}}", + data: &TemplateData{Name: "test"}, + errMsg: "template parsing error", + }, + } + + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + _, err := executeTemplate(tc.template, tc.data) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errMsg) + }) + } +} + +func TestSanitizeName(t *testing.T) { + testCases := []struct { + input string + expected string + }{ + {"My Policy", "my-policy"}, + {" Trim Spaces ", "trim-spaces"}, + {"UPPER CASE", "upper-case"}, + {"Special!@#Chars", "special!@#chars"}, + {"", ""}, + } + + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.expected, sanitizeName(tc.input)) + }) + } +} diff --git a/app/cli/internal/policydevel/templates/example-policy.rego b/app/cli/internal/policydevel/templates/example-policy.rego new file mode 100644 index 000000000..cb1edcb03 --- /dev/null +++ b/app/cli/internal/policydevel/templates/example-policy.rego @@ -0,0 +1,39 @@ +package main + +import rego.v1 + +################################ +# Common section do NOT change # +################################ + +result := { + "skipped": skipped, + "violations": violations, + "skip_reason": skip_reason, +} + +default skip_reason := "" + +skip_reason := m if { + not valid_input + m := "invalid input" +} + +default skipped := true + +skipped := false if valid_input + +######################################## +# EO Common section, custom code below # +######################################## + +# Validates if the input is valid and can be understood by this policy +valid_input if { + # insert code here +} + +# If the input is valid, check for any policy violation here +violations contains msg if { + valid_input + # insert code here +} diff --git a/app/cli/internal/policydevel/templates/example-policy.yaml b/app/cli/internal/policydevel/templates/example-policy.yaml new file mode 100644 index 000000000..76fea0999 --- /dev/null +++ b/app/cli/internal/policydevel/templates/example-policy.yaml @@ -0,0 +1,24 @@ +# Policy generated by Chainloop CLI +# +# For policy examples and reference: +# https://github.com/chainloop-dev/chainloop/tree/main/docs/examples/policies +apiVersion: workflowcontract.chainloop.dev/v1 +kind: Policy +metadata: + name: {{.Name | sanitize}} + description: {{.Description | trimSpace}} +spec: + policies: + # Type of artifact to validate + # See docs: https://docs.chainloop.dev/concepts/material-types + - kind: {{.MaterialKind}} + {{if .Embedded -}} + # Embedded Rego policy + # See docs: https://docs.chainloop.dev/guides/custom-policies#embedded-vs-external + embedded: | +{{.RegoContent | indent 8}} + {{else -}} + # Path to external Rego policy file + # See docs: https://docs.chainloop.dev/guides/custom-policies#rego-policy-structure + path: {{.RegoPath}} + {{end -}} \ No newline at end of file