diff --git a/app/cli/cmd/policy_develop_eval.go b/app/cli/cmd/policy_develop_eval.go index 0b0565939..097578aca 100644 --- a/app/cli/cmd/policy_develop_eval.go +++ b/app/cli/cmd/policy_develop_eval.go @@ -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)") diff --git a/app/cli/internal/policydevel/eval.go b/app/cli/internal/policydevel/eval.go index 8f1ab3100..c34f49713 100644 --- a/app/cli/internal/policydevel/eval.go +++ b/app/cli/internal/policydevel/eval.go @@ -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 { @@ -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 @@ -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) @@ -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(), @@ -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)) } @@ -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) { diff --git a/app/cli/internal/policydevel/eval_test.go b/app/cli/internal/policydevel/eval_test.go index cc45e2fa1..4b6007194 100644 --- a/app/cli/internal/policydevel/eval_test.go +++ b/app/cli/internal/policydevel/eval_test.go @@ -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") + }) +} diff --git a/app/cli/internal/policydevel/testdata/generic-policy.yaml b/app/cli/internal/policydevel/testdata/generic-policy.yaml new file mode 100644 index 000000000..71507fccb --- /dev/null +++ b/app/cli/internal/policydevel/testdata/generic-policy.yaml @@ -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" + } diff --git a/app/cli/internal/policydevel/testdata/kind-only-policy.yaml b/app/cli/internal/policydevel/testdata/kind-only-policy.yaml new file mode 100644 index 000000000..463ed3e0d --- /dev/null +++ b/app/cli/internal/policydevel/testdata/kind-only-policy.yaml @@ -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 + } diff --git a/app/cli/internal/policydevel/testdata/multi-kind-policy.yaml b/app/cli/internal/policydevel/testdata/multi-kind-policy.yaml new file mode 100644 index 000000000..e47745881 --- /dev/null +++ b/app/cli/internal/policydevel/testdata/multi-kind-policy.yaml @@ -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 + } diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 1dfe21af9..7a13c9ef8 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -159,6 +159,25 @@ func (pv *PolicyVerifier) VerifyMaterial(ctx context.Context, material *v12.Atte return result, nil } +// EvaluateGeneric evaluates a single policy attachment. +func (pv *PolicyVerifier) EvaluateGeneric(ctx context.Context, attachment *v1.PolicyAttachment) (*v12.PolicyEvaluation, error) { + // Use empty JSON as material input + input := []byte("{}") + + // Evaluate without material context + ev, err := pv.evaluatePolicyAttachment(ctx, attachment, input, + &evalOpts{ + kind: v1.CraftingSchema_Material_MATERIAL_TYPE_UNSPECIFIED, + name: "", + }, + ) + if err != nil { + return nil, NewPolicyError(err) + } + + return ev, nil +} + type evalOpts struct { name string kind v1.CraftingSchema_Material_MaterialType