Skip to content
Merged
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
34 changes: 20 additions & 14 deletions app/cli/cmd/policy_develop_eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ import (

func newPolicyDevelopEvalCmd() *cobra.Command {
var (
materialPath string
kind string
annotations []string
policyPath string
inputs []string
allowedHostnames []string
materialPath string
kind string
annotations []string
policyPath string
inputs []string
allowedHostnames []string
projectName string
projectVersionName string
)

cmd := &cobra.Command{
Expand All @@ -41,18 +43,20 @@ func newPolicyDevelopEvalCmd() *cobra.Command {
Long: `Perform a full evaluation of the policy against the provided material type.
The command checks if there is a path in the policy for the specified kind and
evaluates the policy against the provided material or attestation.`,
Example: `
Example: `
# Evaluate policy against a material file
chainloop policy develop eval --policy policy.yaml --material sbom.json --kind SBOM_CYCLONEDX_JSON --annotation key1=value1,key2=value2 --input key3=value3`,
RunE: func(_ *cobra.Command, _ []string) error {
opts := &action.PolicyEvalOpts{
MaterialPath: materialPath,
Kind: kind,
Annotations: parseKeyValue(annotations),
PolicyPath: policyPath,
Inputs: parseKeyValue(inputs),
AllowedHostnames: allowedHostnames,
Debug: flagDebug,
MaterialPath: materialPath,
Kind: kind,
Annotations: parseKeyValue(annotations),
PolicyPath: policyPath,
Inputs: parseKeyValue(inputs),
AllowedHostnames: allowedHostnames,
Debug: flagDebug,
ProjectName: projectName,
ProjectVersionName: projectVersionName,
}

policyEval, err := action.NewPolicyEval(opts, ActionOpts)
Expand All @@ -76,6 +80,8 @@ evaluates the policy against the provided material or attestation.`,
cmd.Flags().StringVarP(&policyPath, "policy", "p", "policy.yaml", "Policy reference (./my-policy.yaml, https://my-domain.com/my-policy.yaml, chainloop://my-stored-policy)")
cmd.Flags().StringArrayVar(&inputs, "input", []string{}, "Key-value pairs of policy inputs (key=value)")
cmd.Flags().StringSliceVar(&allowedHostnames, "allowed-hostnames", []string{}, "Additional hostnames allowed for http.send requests in policies")
cmd.Flags().StringVar(&projectName, "project", "", "Project name to use as engine context for chainloop.* built-ins")
cmd.Flags().StringVar(&projectVersionName, "project-version", "", "Project version to use as engine context for chainloop.* built-ins")

return cmd
}
Expand Down
2 changes: 2 additions & 0 deletions app/cli/documentation/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2940,6 +2940,8 @@ Options
--kind string Kind of the material: ["ARTIFACT" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CHAINLOOP_AI_AGENT_CONFIG" "CHAINLOOP_AI_CODING_SESSION" "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" "GITLEAKS_JSON" "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, chainloop://my-stored-policy) (default "policy.yaml")
--project string Project name to use as engine context for chainloop.* built-ins
--project-version string Project version to use as engine context for chainloop.* built-ins
```

Options inherited from parent commands
Expand Down
27 changes: 16 additions & 11 deletions app/cli/internal/policydevel/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ const (
)

type EvalOptions struct {
PolicyPath string
MaterialKind string
Annotations map[string]string
MaterialPath string
Inputs map[string]string
AllowedHostnames []string
Debug bool
AttestationClient controlplanev1.AttestationServiceClient
ControlPlaneConn *grpc.ClientConn
PolicyPath string
MaterialKind string
Annotations map[string]string
MaterialPath string
Inputs map[string]string
AllowedHostnames []string
Debug bool
AttestationClient controlplanev1.AttestationServiceClient
ControlPlaneConn *grpc.ClientConn
ProjectName string
ProjectVersionName string
}

type EvalResult struct {
Expand Down Expand Up @@ -80,7 +82,7 @@ func Evaluate(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) {
material.Annotations = opts.Annotations

// 3. Verify material against policy
summary, err := verifyMaterial(policies, material, opts.MaterialPath, opts.Debug, opts.AllowedHostnames, opts.AttestationClient, opts.ControlPlaneConn, &logger)
summary, err := verifyMaterial(policies, material, opts.MaterialPath, opts.Debug, opts.AllowedHostnames, opts.AttestationClient, opts.ControlPlaneConn, opts.ProjectName, opts.ProjectVersionName, &logger)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -108,7 +110,7 @@ func createPolicies(policyPath string, inputs map[string]string) (*v1.Policies,
}, nil
}

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) {
func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materialPath string, debug bool, allowedHostnames []string, attestationClient controlplanev1.AttestationServiceClient, grpcConn *grpc.ClientConn, projectName, projectVersion string, logger *zerolog.Logger) (*EvalSummary, error) {
var opts []policies.PolicyVerifierOption
if len(allowedHostnames) > 0 {
opts = append(opts, policies.WithAllowedHostnames(allowedHostnames...))
Expand All @@ -117,6 +119,9 @@ func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materi
opts = append(opts, policies.WithIncludeRawData(debug))
opts = append(opts, policies.WithEnablePrint(enablePrint))
opts = append(opts, policies.WithGRPCConn(grpcConn))
if projectName != "" || projectVersion != "" {
opts = append(opts, policies.WithProjectContext(projectName, projectVersion))
}

v := policies.NewPolicyVerifier(pol, attestationClient, logger, opts...)
policyEvs, err := v.VerifyMaterial(context.Background(), material, materialPath)
Expand Down
36 changes: 20 additions & 16 deletions app/cli/pkg/action/policy_develop_eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import (
)

type PolicyEvalOpts struct {
MaterialPath string
Kind string
Annotations map[string]string
PolicyPath string
Inputs map[string]string
AllowedHostnames []string
Debug bool
MaterialPath string
Kind string
Annotations map[string]string
PolicyPath string
Inputs map[string]string
AllowedHostnames []string
Debug bool
ProjectName string
ProjectVersionName string
}

type PolicyEval struct {
Expand All @@ -50,15 +52,17 @@ func (action *PolicyEval) Run() (*policydevel.EvalSummary, error) {
}

evalOpts := &policydevel.EvalOptions{
PolicyPath: action.opts.PolicyPath,
MaterialKind: action.opts.Kind,
Annotations: action.opts.Annotations,
MaterialPath: action.opts.MaterialPath,
Inputs: action.opts.Inputs,
AllowedHostnames: action.opts.AllowedHostnames,
Debug: action.opts.Debug,
AttestationClient: attClient,
ControlPlaneConn: action.CPConnection,
PolicyPath: action.opts.PolicyPath,
MaterialKind: action.opts.Kind,
Annotations: action.opts.Annotations,
MaterialPath: action.opts.MaterialPath,
Inputs: action.opts.Inputs,
AllowedHostnames: action.opts.AllowedHostnames,
Debug: action.opts.Debug,
AttestationClient: attClient,
ControlPlaneConn: action.CPConnection,
ProjectName: action.opts.ProjectName,
ProjectVersionName: action.opts.ProjectVersionName,
}

// Evaluate policy
Expand Down
23 changes: 23 additions & 0 deletions pkg/attestation/crafter/crafter.go
Original file line number Diff line number Diff line change
Expand Up @@ -722,13 +722,16 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M
return i.MaterialName == m.Name
})

projectName, projectVersion := c.projectContext()

pgv := policies.NewPolicyGroupVerifier(
c.CraftingState.GetPolicyGroups(),
c.CraftingState.GetPolicies(),
c.attClient,
c.Logger,
policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...),
policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()),
policies.WithProjectContext(projectName, projectVersion),
)
policyGroupResults, err := pgv.VerifyMaterial(ctx, mt, value)
if err != nil {
Expand All @@ -746,6 +749,7 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M
c.Logger,
policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...),
policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()),
policies.WithProjectContext(projectName, projectVersion),
)
policyResults, err := pv.VerifyMaterial(ctx, mt, value)
if err != nil {
Expand Down Expand Up @@ -773,6 +777,21 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M
return mt, nil
}

// projectContext returns the project name and version from the workflow
// metadata so policy verifiers can pass them to the engine. Either may be
// empty (e.g. dry-run before workflow metadata is populated); built-ins
// must degrade gracefully in that case.
func (c *Crafter) projectContext() (string, string) {
wf := c.CraftingState.GetAttestation().GetWorkflow()
version := wf.GetVersion().GetVersion()
if version == "" {
// Fall back to the deprecated flat field for state written by older clients.
//nolint:staticcheck // intentional fallback for backwards compatibility
version = wf.GetProjectVersion()
}
return wf.GetProject(), version
}

// policyEvalMatches returns true if two policy evaluations refer to the same policy
// with the same arguments. It treats nil and empty maps as equivalent to handle
// protojson round-trip serialization where empty maps are omitted.
Expand All @@ -783,11 +802,14 @@ func policyEvalMatches(a, b *api.PolicyEvaluation) bool {
// EvaluateAttestationPolicies evaluates the attestation-level policies and stores them in the attestation state.
// The phase parameter controls which policies are evaluated based on their attestation_phases spec field.
func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID string, statement *intoto.Statement, phase policies.EvalPhase) error {
projectName, projectVersion := c.projectContext()

// evaluate attestation-level policies
pv := policies.NewPolicyVerifier(c.CraftingState.GetPolicies(), c.attClient, c.Logger,
policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...),
policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()),
policies.WithEvalPhase(phase),
policies.WithProjectContext(projectName, projectVersion),
)
policyEvaluations, err := pv.VerifyStatement(ctx, statement)
if err != nil {
Expand All @@ -798,6 +820,7 @@ func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID
policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...),
policies.WithDefaultGate(c.CraftingState.Attestation.GetBlockOnPolicyViolation()),
policies.WithEvalPhase(phase),
policies.WithProjectContext(projectName, projectVersion),
)
policyGroupResults, err := pgv.VerifyStatement(ctx, statement)
if err != nil {
Expand Down
19 changes: 19 additions & 0 deletions pkg/policies/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ type CommonEngineOptions struct {
IncludeRawData bool
EnablePrint bool
ControlPlaneConnection *grpc.ClientConn
// ProjectName / ProjectVersionName carry the project + version this engine
// instance is evaluating policies for. They are surfaced to chainloop.* built-ins
// via the per-evaluation context.Context (see builtins.WithProjectContext) so a
// built-in like chainloop.findings can scope its query without the rego author
// having to pass the values explicitly. Either may be empty (e.g. local dev
// eval without flags) — built-ins must degrade gracefully in that case.
ProjectName string
ProjectVersionName string
}

// Option is a unified functional option for configuring policy engines
Expand Down Expand Up @@ -106,6 +114,17 @@ func WithGRPCConn(conn *grpc.ClientConn) Option {
}
}

// WithProjectContext sets the project name and version that this engine
// instance is evaluating policies for. The values are propagated to chainloop.*
// built-ins through the per-evaluation context so they can scope queries
// (e.g. chainloop.findings) without the rego author passing them explicitly.
func WithProjectContext(name, version string) Option {
return func(opts *Options) {
opts.ProjectName = name
opts.ProjectVersionName = version
}
}

// ApplyOptions applies options and returns the configured Options
// This automatically appends BaseAllowedHostnames to any user-provided hostnames
func ApplyOptions(opts ...Option) *Options {
Expand Down
44 changes: 44 additions & 0 deletions pkg/policies/engine/rego/builtins/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2026 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 builtins

import "context"

// ProjectContext carries the project + version a policy is being evaluated against.
// It is attached to the per-evaluation context.Context by the rego engine so that
// chainloop.* built-ins can scope their requests (e.g. chainloop.findings) without
// requiring the rego author to pass project_name / project_version_name explicitly.
//
// Values may be empty when the engine has no project context (e.g. a local
// `chainloop policy develop eval` without --project flags). Built-ins must
// degrade gracefully in that case rather than erroring.
type ProjectContext struct {
Name string
Version string
}

type projectContextKey struct{}

// WithProjectContext returns a derived context carrying the given project context.
func WithProjectContext(ctx context.Context, pc ProjectContext) context.Context {
return context.WithValue(ctx, projectContextKey{}, pc)
}

// ProjectContextFromContext returns the project context attached to ctx, or the
// zero value if none was set. The bool reports whether a value was present.
func ProjectContextFromContext(ctx context.Context) (ProjectContext, bool) {
pc, ok := ctx.Value(projectContextKey{}).(ProjectContext)
return pc, ok
}
64 changes: 64 additions & 0 deletions pkg/policies/engine/rego/builtins/context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2026 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 builtins

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
)

func TestProjectContext(t *testing.T) {
tests := []struct {
name string
setup func() context.Context
wantName string
wantVersion string
wantOK bool
}{
{
name: "no project context attached",
setup: context.Background,
wantOK: false,
},
{
name: "context with project + version",
setup: func() context.Context {
return WithProjectContext(context.Background(), ProjectContext{Name: "my-app", Version: "v1.2.3"})
},
wantName: "my-app",
wantVersion: "v1.2.3",
wantOK: true,
},
{
name: "context with only project name",
setup: func() context.Context {
return WithProjectContext(context.Background(), ProjectContext{Name: "my-app"})
},
wantName: "my-app",
wantOK: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pc, ok := ProjectContextFromContext(tt.setup())
assert.Equal(t, tt.wantOK, ok)
assert.Equal(t, tt.wantName, pc.Name)
assert.Equal(t, tt.wantVersion, pc.Version)
})
}
}
Loading
Loading