Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/cli/cmd/policy_develop_eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ evaluates the policy against the provided material or attestation.`,
}

cmd.Flags().StringVar(&materialPath, "material", "", "Path to material or attestation file")
cobra.CheckErr(cmd.MarkFlagRequired("material"))
cmd.Flags().StringVar(&kind, "kind", "", fmt.Sprintf("Kind of the material: %q", schemaapi.ListAvailableMaterialKind()))
cmd.Flags().StringSliceVar(&annotations, "annotation", []string{}, "Key-value pairs of material annotations (key=value)")
cmd.Flags().StringVarP(&policyPath, "policy", "p", "policy.yaml", "Policy reference (./my-policy.yaml, https://my-domain.com/my-policy.yaml, chainloop://my-stored-policy)")
Expand Down
78 changes: 62 additions & 16 deletions app/cli/internal/policydevel/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,25 @@ type EvalSummaryDebugInfo struct {
}

func Evaluate(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) {
// 1. Create crafting schema
policies, err := createPolicies(opts.PolicyPath, opts.Inputs)
if err != nil {
return nil, err
// Check if this is a generic policy evaluation
if opts.MaterialPath == "" {
return evaluateGeneric(opts, logger)
}

// 2. Craft material with annotations
// Material-based evaluation
// 1. Craft material with annotations
material, err := craftMaterial(opts.MaterialPath, opts.MaterialKind, &logger)
if err != nil {
return nil, err
}
material.Annotations = opts.Annotations

// 2. Create policy attachment
policies, err := createPolicies(opts.PolicyPath, opts.Inputs)
if err != nil {
return nil, err
}

// 3. Verify material against policy
summary, err := verifyMaterial(policies, material, opts.MaterialPath, opts.Debug, opts.AllowedHostnames, opts.AttestationClient, opts.ControlPlaneConn, &logger)
if err != nil {
Expand All @@ -85,6 +91,38 @@ func Evaluate(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) {
return summary, nil
}

func evaluateGeneric(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) {
// Create policy attachment without material selector
ref := opts.PolicyPath
scheme, _ := policies.RefParts(opts.PolicyPath)
if scheme == "" {
// Default to file://
ref = fmt.Sprintf("file://%s", opts.PolicyPath)
}

attachment := &v1.PolicyAttachment{
Policy: &v1.PolicyAttachment_Ref{Ref: ref},
With: opts.Inputs,
}

// Create policy verifier
verifierOpts := buildPolicyVerifierOptions(opts.AllowedHostnames, opts.Debug, opts.ControlPlaneConn)
pol := &v1.Policies{}
v := policies.NewPolicyVerifier(pol, opts.AttestationClient, &logger, verifierOpts...)

// Evaluate generic policy
policyEv, err := v.EvaluateGeneric(context.Background(), attachment)
if err != nil {
return nil, err
}

if policyEv == nil {
return nil, fmt.Errorf("no execution branch matched, or all of them were ignored")
}

return buildEvalSummary(policyEv, opts.Debug), nil
}

func createPolicies(policyPath string, inputs map[string]string) (*v1.Policies, error) {
// Check if the policy path already has a scheme (chainloop://, http://, https://, file://)
ref := policyPath
Expand All @@ -106,14 +144,7 @@ func createPolicies(policyPath string, inputs map[string]string) (*v1.Policies,
}

func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materialPath string, debug bool, allowedHostnames []string, attestationClient controlplanev1.AttestationServiceClient, grpcConn *grpc.ClientConn, logger *zerolog.Logger) (*EvalSummary, error) {
var opts []policies.PolicyVerifierOption
if len(allowedHostnames) > 0 {
opts = append(opts, policies.WithAllowedHostnames(allowedHostnames...))
}

opts = append(opts, policies.WithIncludeRawData(debug))
opts = append(opts, policies.WithEnablePrint(enablePrint))
opts = append(opts, policies.WithGRPCConn(grpcConn))
opts := buildPolicyVerifierOptions(allowedHostnames, debug, grpcConn)

v := policies.NewPolicyVerifier(pol, attestationClient, logger, opts...)
policyEvs, err := v.VerifyMaterial(context.Background(), material, materialPath)
Expand All @@ -126,8 +157,23 @@ func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materi
}

// Only one evaluation expected for a single policy attachment
policyEv := policyEvs[0]
return buildEvalSummary(policyEvs[0], debug), nil
}

// buildPolicyVerifierOptions creates common policy verifier options
func buildPolicyVerifierOptions(allowedHostnames []string, debug bool, grpcConn *grpc.ClientConn) []policies.PolicyVerifierOption {
var opts []policies.PolicyVerifierOption
if len(allowedHostnames) > 0 {
opts = append(opts, policies.WithAllowedHostnames(allowedHostnames...))
}
opts = append(opts, policies.WithIncludeRawData(debug))
opts = append(opts, policies.WithGRPCConn(grpcConn))
opts = append(opts, policies.WithEnablePrint(enablePrint))
return opts
}

// buildEvalSummary converts a PolicyEvaluation to an EvalSummary
func buildEvalSummary(policyEv *v12.PolicyEvaluation, debug bool) *EvalSummary {
summary := &EvalSummary{
Result: &EvalResult{
Skipped: policyEv.GetSkipped(),
Expand All @@ -152,7 +198,7 @@ func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materi
if rr == nil {
continue
}
// Take the first input found, as we only allow one material input
// Take the first input found
if len(summary.DebugInfo.Inputs) == 0 && rr.Input != nil {
summary.DebugInfo.Inputs = append(summary.DebugInfo.Inputs, json.RawMessage(rr.Input))
}
Expand All @@ -163,7 +209,7 @@ func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materi
}
}

return summary, nil
return summary
}

func craftMaterial(materialPath, materialKind string, logger *zerolog.Logger) (*v12.Attestation_Material, error) {
Expand Down
131 changes: 131 additions & 0 deletions app/cli/internal/policydevel/eval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,134 @@ func TestEvaluateSimplifiedPolicies(t *testing.T) {
assert.Contains(t, result.Result.Violations[0], "too few components")
})
}

func TestEvaluateGenericPolicies(t *testing.T) {
logger := zerolog.New(os.Stderr)

t.Run("generic policy with valid input - no violations", func(t *testing.T) {
opts := &EvalOptions{
PolicyPath: "testdata/generic-policy.yaml",
Inputs: map[string]string{
"environment": "staging",
"approved": "false",
},
}

result, err := Evaluate(opts, logger)
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.Result.Skipped)
assert.Empty(t, result.Result.SkipReasons)
assert.Empty(t, result.Result.Violations, "Expected no violations for valid staging deployment")
})

t.Run("generic policy with production unapproved - violation", func(t *testing.T) {
opts := &EvalOptions{
PolicyPath: "testdata/generic-policy.yaml",
Inputs: map[string]string{
"environment": "production",
"approved": "false",
},
}

result, err := Evaluate(opts, logger)
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.Result.Skipped)
assert.Len(t, result.Result.Violations, 1)
assert.Contains(t, result.Result.Violations[0], "production requires approval")
})

t.Run("generic policy without required input - error", func(t *testing.T) {
opts := &EvalOptions{
PolicyPath: "testdata/generic-policy.yaml",
Inputs: map[string]string{},
}

_, err := Evaluate(opts, logger)
require.Error(t, err)
assert.Contains(t, err.Error(), "missing required input")
})

t.Run("kind-only policy without material - should fail", func(t *testing.T) {
opts := &EvalOptions{
PolicyPath: "testdata/kind-only-policy.yaml",
Inputs: map[string]string{
"environment": "production",
},
}

_, err := Evaluate(opts, logger)
require.Error(t, err)
assert.Contains(t, err.Error(), "no execution branch matched, or all of them were ignored")
})
}

func TestEvaluateMultiKindPolicies(t *testing.T) {
logger := zerolog.New(os.Stderr)

t.Run("multi-kind policy with generic evaluation - only generic script runs", func(t *testing.T) {
opts := &EvalOptions{
PolicyPath: "testdata/multi-kind-policy.yaml",
Inputs: map[string]string{
"environment": "production",
},
}

result, err := Evaluate(opts, logger)
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.Result.Skipped)

require.Len(t, result.Result.Violations, 1)
assert.Contains(t, result.Result.Violations[0], "Generic check")
assert.NotContains(t, result.Result.Violations[0], "SBOM-specific")
})

t.Run("multi-kind policy with generic evaluation - staging no violations", func(t *testing.T) {
opts := &EvalOptions{
PolicyPath: "testdata/multi-kind-policy.yaml",
Inputs: map[string]string{
"environment": "staging",
},
}

result, err := Evaluate(opts, logger)
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.Result.Skipped)

assert.Empty(t, result.Result.Violations)
})

t.Run("multi-kind policy with SBOM material - only SBOM-specific policy runs", func(t *testing.T) {
tempDir := t.TempDir()

sbomContent := `{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"version": 1,
"components": []
}`
sbomPath := filepath.Join(tempDir, "test-sbom.json")
require.NoError(t, os.WriteFile(sbomPath, []byte(sbomContent), 0600))

opts := &EvalOptions{
PolicyPath: "testdata/multi-kind-policy.yaml",
MaterialPath: sbomPath,
MaterialKind: "SBOM_CYCLONEDX_JSON",
Inputs: map[string]string{
"environment": "production",
},
}

result, err := Evaluate(opts, logger)
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.Result.Skipped)

require.Len(t, result.Result.Violations, 2)
assert.Contains(t, result.Result.Violations[0], "Generic check")
assert.Contains(t, result.Result.Violations[1], "SBOM-specific check")
})
}
32 changes: 32 additions & 0 deletions app/cli/internal/policydevel/testdata/generic-policy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
apiVersion: workflowcontract.chainloop.dev/v1
kind: Policy
metadata:
name: deployment-validation
description: Validates deployment configuration for any environment
spec:
inputs:
- name: environment
description: Deployment environment (e.g., staging, production)
required: true
- name: approved
description: Whether the deployment is approved (true/false)
required: false
policies:
- embedded: |
package main

import rego.v1

result := {
"violations": violations,
}

# Convert string to boolean for approved
is_approved := input.args.approved == "true"

# Policy checks
violations contains msg if {
input.args.environment == "production"
not is_approved
msg := "Deployment to production requires approval"
}
35 changes: 35 additions & 0 deletions app/cli/internal/policydevel/testdata/kind-only-policy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
apiVersion: workflowcontract.chainloop.dev/v1
kind: Policy
metadata:
name: kind-only-policy
description: Policy with only kind-specific scripts (no generic)
spec:
inputs:
- name: environment
description: Deployment environment
required: true
policies:
- kind: SBOM_CYCLONEDX_JSON
embedded: |
package main
import rego.v1

result := {
"violations": violations,
}

violations contains "SBOM check failed" if {
true
}
- kind: CONTAINER_IMAGE
embedded: |
package main
import rego.v1

result := {
"violations": violations,
}

violations contains "Container check failed" if {
true
}
35 changes: 35 additions & 0 deletions app/cli/internal/policydevel/testdata/multi-kind-policy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
apiVersion: workflowcontract.chainloop.dev/v1
kind: Policy
metadata:
name: multi-kind-policy
description: Policy with multiple kinds - generic and material-specific
spec:
inputs:
- name: environment
description: Deployment environment
required: true
policies:
- embedded: |
package main
import rego.v1

result := {
"violations": violations,
}

violations contains msg if {
input.args.environment == "production"
msg := "Generic check: production deployments require additional validation"
}
- kind: SBOM_CYCLONEDX_JSON
embedded: |
package main
import rego.v1

result := {
"violations": violations,
}

violations contains "SBOM-specific check: this should not run for generic evaluation" if {
true
}
Loading
Loading