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
2 changes: 1 addition & 1 deletion app/cli/cmd/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ func newPolicyCmd() *cobra.Command {
Short: "Craft chainloop policies",
}

cmd.AddCommand(newPolicyDevelopCmd())
cmd.AddCommand(newPolicyEvalCmd(), newPolicyDevelopCmd())
return cmd
}
80 changes: 80 additions & 0 deletions app/cli/cmd/policy_eval.go
Original file line number Diff line number Diff line change
@@ -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.

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
}
50 changes: 50 additions & 0 deletions app/cli/documentation/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3017,6 +3017,56 @@ Options inherited from parent commands
-y, --yes Skip confirmation
```

### chainloop policy eval

Evaluate a policy

Synopsis

Evaluate a policy.

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_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
Expand Down
179 changes: 179 additions & 0 deletions app/cli/pkg/action/policy_eval.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//
// 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"

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/attestation/crafter/materials"
"github.com/chainloop-dev/chainloop/pkg/casclient"
"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 := action.craftMaterial(ctx)
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
}

func (action *PolicyEvaluate) craftMaterial(ctx context.Context) (*attestationapi.Attestation_Material, error) {
backend := &casclient.CASBackend{
Name: "backend",
MaxSize: 0,
Uploader: nil, // Skip uploads
}

// Explicit kind
if action.opts.Kind != "" {
kind, ok := schemaapi.CraftingSchema_Material_MaterialType_value[action.opts.Kind]
if !ok {
return nil, fmt.Errorf("invalid material kind: %s", action.opts.Kind)
}
return action.craft(ctx, schemaapi.CraftingSchema_Material_MaterialType(kind), "material", backend)
}

// Auto-detect kind
for _, kind := range schemaapi.CraftingMaterialInValidationOrder {
m, err := action.craft(ctx, kind, "auto-detected-material", backend)
if err == nil {
return m, nil
}
}

return nil, fmt.Errorf("could not auto-detect material kind for: %s", action.opts.MaterialPath)
}

func (action *PolicyEvaluate) craft(ctx context.Context, kind schemaapi.CraftingSchema_Material_MaterialType, name string, backend *casclient.CASBackend) (*attestationapi.Attestation_Material, error) {
materialSchema := &schemaapi.CraftingSchema_Material{
Type: kind,
Name: name,
}

m, err := materials.Craft(ctx, materialSchema, action.opts.MaterialPath, backend, nil, &action.Logger)
if err != nil {
return nil, fmt.Errorf("failed to craft material (kind=%s): %w", kind.String(), err)
}
return m, nil
}
Loading
Loading