diff --git a/app/cli/cmd/policy.go b/app/cli/cmd/policy.go index db34a4425..e7dcb308e 100644 --- a/app/cli/cmd/policy.go +++ b/app/cli/cmd/policy.go @@ -25,6 +25,6 @@ func newPolicyCmd() *cobra.Command { Short: "Craft chainloop policies", } - cmd.AddCommand(newPolicyDevelopCmd()) + cmd.AddCommand(newPolicyEvalCmd(), newPolicyDevelopCmd()) return cmd } diff --git a/app/cli/cmd/policy_eval.go b/app/cli/cmd/policy_eval.go new file mode 100644 index 000000000..a648c444f --- /dev/null +++ b/app/cli/cmd/policy_eval.go @@ -0,0 +1,80 @@ +// +// 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/cmd/output" + "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/spf13/cobra" +) + +func newPolicyEvalCmd() *cobra.Command { + var ( + materialPath string + kind string + annotations []string + policyPath string + inputs []string + ) + + cmd := &cobra.Command{ + Use: "eval", + Short: "Evaluate a policy", + Long: `Evaluate a policy with organization settings. + +This command uses organization context to evaluate policies. + +For offline development and testing with debug capabilities, use 'chainloop policy develop eval' instead.`, + Example: ` + chainloop policy eval --policy policy.yaml --input digest=sha256:80058e45a56daa50ae2a130bd1bd13b1fb9aff13a55b2d98615fff6eb3b0fffb`, + Annotations: map[string]string{ + useAPIToken: trueString, + }, + RunE: func(cmd *cobra.Command, _ []string) error { + opts := &action.PolicyEvaluateOpts{ + MaterialPath: materialPath, + Kind: kind, + Annotations: parseKeyValue(annotations), + PolicyPath: policyPath, + Inputs: parseKeyValue(inputs), + } + + policyEval, err := action.NewPolicyEvaluate(opts, ActionOpts) + if err != nil { + return err + } + + result, err := policyEval.Run(cmd.Context()) + if err != nil { + return err + } + + return output.EncodeJSON(result) + }, + } + + cmd.Flags().StringVar(&materialPath, "material", "", "Path to material or attestation file") + 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 reference (./my-policy.yaml, https://my-domain.com/my-policy.yaml)") + cobra.CheckErr(cmd.MarkFlagRequired("policy")) + cmd.Flags().StringArrayVar(&inputs, "input", []string{}, "Key-value pairs of policy inputs (key=value)") + + return cmd +} diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 21c14c386..03048b4e2 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -3017,6 +3017,56 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` +### chainloop policy eval + +Evaluate a policy + +Synopsis + +Evaluate a policy with organization settings. + +This command uses organization context to evaluate policies. + +For offline development and testing with debug capabilities, use 'chainloop policy develop eval' instead. + +``` +chainloop policy eval [flags] +``` + +Examples + +``` + +chainloop policy eval --policy policy.yaml --input digest=sha256:80058e45a56daa50ae2a130bd1bd13b1fb9aff13a55b2d98615fff6eb3b0fffb +``` + +Options + +``` +--annotation strings Key-value pairs of material annotations (key=value) +-h, --help help for eval +--input stringArray Key-value pairs of policy inputs (key=value) +--kind string Kind of the material: ["ARTIFACT" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CHAINLOOP_PR_INFO" "CHAINLOOP_RUNNER_CONTEXT" "CONTAINER_IMAGE" "CSAF_INFORMATIONAL_ADVISORY" "CSAF_SECURITY_ADVISORY" "CSAF_SECURITY_INCIDENT_RESPONSE" "CSAF_VEX" "EVIDENCE" "GHAS_CODE_SCAN" "GHAS_DEPENDENCY_SCAN" "GHAS_SECRET_SCAN" "GITLAB_SECURITY_REPORT" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENVEX" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "TWISTCLI_SCAN_JSON" "ZAP_DAST_ZIP"] +--material string Path to material or attestation file +-p, --policy string Policy reference (./my-policy.yaml, https://my-domain.com/my-policy.yaml) +``` + +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 diff --git a/app/cli/internal/policydevel/eval.go b/app/cli/internal/policydevel/eval.go index 8f1ab3100..b08849977 100644 --- a/app/cli/internal/policydevel/eval.go +++ b/app/cli/internal/policydevel/eval.go @@ -70,7 +70,7 @@ func Evaluate(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) { } // 2. Craft material with annotations - material, err := craftMaterial(opts.MaterialPath, opts.MaterialKind, &logger) + material, err := CraftMaterial(opts.MaterialPath, opts.MaterialKind, &logger) if err != nil { return nil, err } @@ -166,7 +166,9 @@ func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materi return summary, nil } -func craftMaterial(materialPath, materialKind string, logger *zerolog.Logger) (*v12.Attestation_Material, error) { +// CraftMaterial creates an attestation material from a file path, with optional explicit kind or auto-detection. +// This is a shared utility function used by both policy eval and policy devel eval commands. +func CraftMaterial(materialPath, materialKind string, logger *zerolog.Logger) (*v12.Attestation_Material, error) { backend := &casclient.CASBackend{ Name: "backend", MaxSize: 0, diff --git a/app/cli/pkg/action/policy_eval.go b/app/cli/pkg/action/policy_eval.go new file mode 100644 index 000000000..331a102e9 --- /dev/null +++ b/app/cli/pkg/action/policy_eval.go @@ -0,0 +1,138 @@ +// +// 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 ( + "context" + "fmt" + + "github.com/chainloop-dev/chainloop/app/cli/internal/policydevel" + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + attestationapi "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/pkg/policies" +) + +type PolicyEvaluateOpts struct { + MaterialPath string + Kind string + Annotations map[string]string + PolicyPath string + Inputs map[string]string +} + +type PolicyEvaluate struct { + *ActionsOpts + opts *PolicyEvaluateOpts +} + +func NewPolicyEvaluate(opts *PolicyEvaluateOpts, actionOpts *ActionsOpts) (*PolicyEvaluate, error) { + if actionOpts.CPConnection == nil { + return nil, fmt.Errorf("control plane connection is required") + } + + return &PolicyEvaluate{ + ActionsOpts: actionOpts, + opts: opts, + }, nil +} + +func (action *PolicyEvaluate) Run(ctx context.Context) (*attestationapi.PolicyEvaluation, error) { + // 1. Get organization settings + contextClient := pb.NewContextServiceClient(action.CPConnection) + contextResp, err := contextClient.Current(ctx, &pb.ContextServiceCurrentRequest{}) + if err != nil { + return nil, fmt.Errorf("fetching organization settings: %w", err) + } + + if contextResp.Result == nil || contextResp.Result.CurrentMembership == nil || contextResp.Result.CurrentMembership.Org == nil { + return nil, fmt.Errorf("no organization context found") + } + + org := contextResp.Result.CurrentMembership.Org + allowedHostnames := org.PolicyAllowedHostnames + + // 2. Create policy attachment + ref := action.opts.PolicyPath + scheme, _ := policies.RefParts(action.opts.PolicyPath) + if scheme == "" { + // If no scheme, assume it's a file path and add file:// prefix + ref = fmt.Sprintf("file://%s", action.opts.PolicyPath) + } + + policyAttachment := &schemaapi.PolicyAttachment{ + Policy: &schemaapi.PolicyAttachment_Ref{Ref: ref}, + With: action.opts.Inputs, + } + + // 3. Create policies structure based on whether we have a material + var pol *schemaapi.Policies + if action.opts.MaterialPath != "" { + // Material-based evaluation + pol = &schemaapi.Policies{ + Materials: []*schemaapi.PolicyAttachment{policyAttachment}, + } + } else { + // Generic evaluation + pol = &schemaapi.Policies{} + } + + // 4. Create policy verifier with organization's allowed hostnames + verifierOpts := []policies.PolicyVerifierOption{ + policies.WithIncludeRawData(false), + policies.WithEnablePrint(false), + policies.WithGRPCConn(action.CPConnection), + } + if len(allowedHostnames) > 0 { + verifierOpts = append(verifierOpts, policies.WithAllowedHostnames(allowedHostnames...)) + } + + attClient := pb.NewAttestationServiceClient(action.CPConnection) + verifier := policies.NewPolicyVerifier(pol, attClient, &action.Logger, verifierOpts...) + + // 5. Evaluate: either material-based or generic + if action.opts.MaterialPath != "" { + // Material-based evaluation + material, err := policydevel.CraftMaterial(action.opts.MaterialPath, action.opts.Kind, &action.Logger) + if err != nil { + return nil, fmt.Errorf("crafting material: %w", err) + } + material.Annotations = action.opts.Annotations + + policyEvs, err := verifier.VerifyMaterial(ctx, material, action.opts.MaterialPath) + if err != nil { + return nil, fmt.Errorf("evaluating policy against material: %w", err) + } + + if len(policyEvs) == 0 || policyEvs[0] == nil { + return nil, fmt.Errorf("no execution branch matched, or all of them were ignored, for kind %s", material.MaterialType.String()) + } + + return policyEvs[0], nil + } + + // Generic evaluation + policyEv, err := verifier.EvaluateGeneric(ctx, policyAttachment) + if err != nil { + return nil, fmt.Errorf("evaluating policy: %w", err) + } + + if policyEv == nil { + return nil, fmt.Errorf("no execution branch matched, or all of them were ignored") + } + + return policyEv, nil +} diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index d82170c2b..f436c6241 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