Skip to content
25 changes: 11 additions & 14 deletions app/cli/internal/policydevel/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ type EvalSummaryDebugInfo struct {

func Evaluate(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) {
// 1. Create crafting schema
schema, err := createCraftingSchema(opts.PolicyPath, opts.Inputs)
policies, err := createPolicies(opts.PolicyPath, opts.Inputs)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please make sure to add comments in the PR about the non obvious things, this change is quite puzzling, thanks

Copy link
Collaborator Author

@Piskoo Piskoo Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, PolicyVerifier and PolicyGroupVerifier constructor was changed to take crafting schema policies and crafting schema policy groups and policies respectively, these were the only fields of crafting schema that were used. As consequence, the evaluation tool that previously was building the whole schema just to pass it to PolicyVerifier contructor, was changed as well. Rename in highlighted code is related to renaming function that was previously building the schema.

if err != nil {
return nil, err
}
Expand All @@ -74,30 +74,27 @@ func Evaluate(opts *EvalOptions, logger zerolog.Logger) (*EvalSummary, error) {
material.Annotations = opts.Annotations

// 3. Verify material against policy
summary, err := verifyMaterial(schema, material, opts.MaterialPath, opts.Debug, opts.AllowedHostnames, &logger)
summary, err := verifyMaterial(policies, material, opts.MaterialPath, opts.Debug, opts.AllowedHostnames, &logger)
if err != nil {
return nil, err
}

return summary, nil
}

func createCraftingSchema(policyPath string, inputs map[string]string) (*v1.CraftingSchema, error) {
return &v1.CraftingSchema{
Policies: &v1.Policies{
Materials: []*v1.PolicyAttachment{
{
Policy: &v1.PolicyAttachment_Ref{Ref: fmt.Sprintf("file://%s", policyPath)},
With: inputs,
},
func createPolicies(policyPath string, inputs map[string]string) (*v1.Policies, error) {
return &v1.Policies{
Materials: []*v1.PolicyAttachment{
{
Policy: &v1.PolicyAttachment_Ref{Ref: fmt.Sprintf("file://%s", policyPath)},
With: inputs,
},
Attestation: nil,
},
PolicyGroups: nil,
Attestation: nil,
}, nil
}

func verifyMaterial(schema *v1.CraftingSchema, material *v12.Attestation_Material, materialPath string, debug bool, allowedHostnames []string, logger *zerolog.Logger) (*EvalSummary, error) {
func verifyMaterial(pol *v1.Policies, material *v12.Attestation_Material, materialPath string, debug bool, allowedHostnames []string, logger *zerolog.Logger) (*EvalSummary, error) {
var opts []policies.PolicyVerifierOption
if len(allowedHostnames) > 0 {
opts = append(opts, policies.WithAllowedHostnames(allowedHostnames...))
Expand All @@ -106,7 +103,7 @@ func verifyMaterial(schema *v1.CraftingSchema, material *v12.Attestation_Materia
opts = append(opts, policies.WithIncludeRawData(debug))
opts = append(opts, policies.WithEnablePrint(enablePrint))

v := policies.NewPolicyVerifier(schema, nil, logger, opts...)
v := policies.NewPolicyVerifier(pol, nil, logger, opts...)
policyEvs, err := v.VerifyMaterial(context.Background(), material, materialPath)
if err != nil {
return nil, err
Expand Down
36 changes: 36 additions & 0 deletions app/cli/pkg/action/attestation_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/chainloop-dev/chainloop/app/cli/internal/token"
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/unmarshal"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
clientAPI "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/chainloop-dev/chainloop/pkg/policies"
Expand Down Expand Up @@ -225,13 +226,20 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun
}
}

// Parse the raw contract to get V2 schema if available
var schemaV2 *v1.CraftingSchemaV2
if contractVersion.GetRawContract() != nil {
schemaV2 = parseContractV2(contractVersion.GetRawContract())
}

// Initialize the local attestation crafter
// NOTE: important to run this initialization here since workflowMeta is populated
// with the workflowRunId that comes from the control plane
initOpts := &crafter.InitOpts{
WfInfo: workflowMeta,
//nolint:staticcheck // TODO: Migrate to new contract version API
SchemaV1: contractVersion.GetV1(),
SchemaV2: schemaV2,
DryRun: action.dryRun,
AttestationID: attestationID,
Runner: discoveredRunner,
Expand Down Expand Up @@ -360,3 +368,31 @@ func extractAuthInfo(authToken string) (*clientAPI.Attestation_Auth, error) {
Id: parsed.ID,
}, nil
}

// parseContractV2 attempts to parse a raw contract as V2 schema
func parseContractV2(rawContract *pb.WorkflowContractVersionItem_RawBody) *v1.CraftingSchemaV2 {
if rawContract == nil {
return nil
}

rawFormat := func() unmarshal.RawFormat {
switch rawContract.GetFormat() {
case pb.WorkflowContractVersionItem_RawBody_FORMAT_JSON:
return unmarshal.RawFormatJSON
case pb.WorkflowContractVersionItem_RawBody_FORMAT_YAML:
return unmarshal.RawFormatYAML
case pb.WorkflowContractVersionItem_RawBody_FORMAT_CUE:
return unmarshal.RawFormatCUE
default:
return unmarshal.RawFormatYAML
}
}()

schemaV2 := &v1.CraftingSchemaV2{}
if err := unmarshal.FromRaw(rawContract.GetBody(), rawFormat, schemaV2, true); err != nil {
// If V2 parsing fails, return nil
return nil
}

return schemaV2
}
86 changes: 86 additions & 0 deletions app/cli/pkg/action/attestation_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ package action

import (
"context"
"os"
"slices"
"testing"

pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -175,3 +177,87 @@ func TestTemplatedGroups(t *testing.T) {
})
}
}

func TestParseContractV2(t *testing.T) {
testCases := []struct {
name string
contractFile string
format pb.WorkflowContractVersionItem_RawBody_Format
expectV2Schema bool
expectName string
expectError bool
}{
{
name: "valid V2 YAML contract",
contractFile: "testdata/contract_v2.yaml",
format: pb.WorkflowContractVersionItem_RawBody_FORMAT_YAML,
expectV2Schema: true,
expectName: "test-contract-v2",
},
{
name: "V1 contract should fail V2 parsing",
contractFile: "testdata/contract_v1.yaml",
format: pb.WorkflowContractVersionItem_RawBody_FORMAT_YAML,
expectV2Schema: false,
},
{
name: "invalid contract data",
contractFile: "testdata/invalid_contract.yaml",
format: pb.WorkflowContractVersionItem_RawBody_FORMAT_YAML,
expectV2Schema: false,
},
{
name: "nil raw contract",
contractFile: "",
format: pb.WorkflowContractVersionItem_RawBody_FORMAT_YAML,
expectV2Schema: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var rawContract *pb.WorkflowContractVersionItem_RawBody

if tc.contractFile != "" {
// Load contract file
data, err := os.ReadFile(tc.contractFile)
if err != nil {
if tc.expectV2Schema {
require.NoError(t, err, "Failed to load contract file")
}
// For non-existent files, test with nil
rawContract = nil
} else {
rawContract = &pb.WorkflowContractVersionItem_RawBody{
Body: data,
Format: tc.format,
}
}
}

result := parseContractV2(rawContract)

if tc.expectV2Schema {
require.NotNil(t, result, "Expected V2 schema to be parsed successfully")
assert.Equal(t, "chainloop.dev/v1", result.GetApiVersion())
assert.Equal(t, "Contract", result.GetKind())
if tc.expectName != "" {
assert.Equal(t, tc.expectName, result.GetMetadata().GetName())
}

// Verify spec fields exist
spec := result.GetSpec()
require.NotNil(t, spec, "Spec should not be nil")
assert.Greater(t, len(spec.GetMaterials()), 0, "Should have materials")
assert.Greater(t, len(spec.GetEnvAllowList()), 0, "Should have env allow list")
assert.NotNil(t, spec.GetRunner(), "Should have runner config")

// Verify annotations in metadata
annotations := result.GetMetadata().GetAnnotations()
assert.NotEmpty(t, annotations, "Should have metadata annotations")
} else {
assert.Nil(t, result, "Expected V2 schema parsing to fail")
}
})
}
}
2 changes: 1 addition & 1 deletion app/cli/pkg/action/attestation_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru
// Annotations
craftedAnnotations := make(map[string]string, 0)
// 1 - Set annotations that come from the contract
for _, v := range crafter.CraftingState.InputSchema.GetAnnotations() {
for _, v := range crafter.CraftingState.GetAnnotations() {
craftedAnnotations[v.Name] = v.Value
}

Expand Down
6 changes: 3 additions & 3 deletions app/cli/pkg/action/attestation_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ func (action *AttestationStatus) Run(ctx context.Context, attestationID string,
},
InitializedAt: toTimePtr(att.InitializedAt.AsTime()),
DryRun: c.CraftingState.DryRun,
Annotations: pbAnnotationsToAction(c.CraftingState.InputSchema.GetAnnotations()),
Annotations: pbAnnotationsToAction(c.CraftingState.GetAnnotations()),
IsPushed: action.isPushed,
MustBlockOnPolicyViolations: att.GetBlockOnPolicyViolation(),
TimestampAuthority: att.GetSigningOptions().GetTimestampAuthorityUrl(),
Expand Down Expand Up @@ -182,7 +182,7 @@ func (action *AttestationStatus) Run(ctx context.Context, attestationID string,

// User defined env variables
envVars := make(map[string]string)
for _, e := range c.CraftingState.InputSchema.EnvAllowList {
for _, e := range c.CraftingState.GetEnvAllowList() {
envVars[e] = ""
if val, found := c.CraftingState.Attestation.EnvVars[e]; found {
envVars[e] = val
Expand Down Expand Up @@ -242,7 +242,7 @@ func getPolicyEvaluations(c *crafter.Crafter) (map[string][]*PolicyEvaluation, b
func populateMaterials(craftingState *v1.CraftingState, res *AttestationStatusResult) error {
visitedMaterials := make(map[string]struct{})
attsMaterials := craftingState.GetAttestation().GetMaterials()
inputSchemaMaterials := craftingState.GetInputSchema().GetMaterials()
inputSchemaMaterials := craftingState.GetMaterials()

if err := populateContractMaterials(inputSchemaMaterials, attsMaterials, res, visitedMaterials); err != nil {
return fmt.Errorf("adding materials from the contract: %w", err)
Expand Down
32 changes: 18 additions & 14 deletions app/cli/pkg/action/attestation_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,15 @@ func TestPopulateContractMaterials(t *testing.T) {
name: "materials on contract",
totalMaterials: 1,
craftingState: &v1.CraftingState{
InputSchema: &craftingpb.CraftingSchema{
SchemaVersion: "v1",
Materials: []*craftingpb.CraftingSchema_Material{
{
Type: craftingpb.CraftingSchema_Material_CSAF_VEX,
Name: "vex-file",
Output: true,
Schema: &v1.CraftingState_InputSchema{
InputSchema: &craftingpb.CraftingSchema{
SchemaVersion: "v1",
Materials: []*craftingpb.CraftingSchema_Material{
{
Type: craftingpb.CraftingSchema_Material_CSAF_VEX,
Name: "vex-file",
Output: true,
},
},
},
},
Expand All @@ -54,13 +56,15 @@ func TestPopulateContractMaterials(t *testing.T) {
name: "materials in contract and outside contract",
totalMaterials: 2,
craftingState: &v1.CraftingState{
InputSchema: &craftingpb.CraftingSchema{
SchemaVersion: "v1",
Materials: []*craftingpb.CraftingSchema_Material{
{
Type: craftingpb.CraftingSchema_Material_CSAF_VEX,
Name: "vex-file",
Output: true,
Schema: &v1.CraftingState_InputSchema{
InputSchema: &craftingpb.CraftingSchema{
SchemaVersion: "v1",
Materials: []*craftingpb.CraftingSchema_Material{
{
Type: craftingpb.CraftingSchema_Material_CSAF_VEX,
Name: "vex-file",
Output: true,
},
},
},
},
Expand Down
48 changes: 48 additions & 0 deletions app/cli/pkg/action/testdata/contract_v1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
schemaVersion: v1
# Arbitrary set of annotations can be added to the contract and will be part of the attestation
annotations:
- name: version
value: oss # if the value is left empty, it will be required and resolved at attestation time
# Three required and one optional materials of three different kinds
materials:
# CONTAINER_IMAGE kinds will get resolved to retrieve their repository digest
- type: CONTAINER_IMAGE
name: skynet-control-plane
# The output flag indicates that the material will be part of the attestation subject
output: true
# Arbitrary annotations can be added to the material
annotations:
- name: component
value: control-plane
# The value can be left empty so it can be provided at attestation time
- name: asset
# ARTIFACT kinds will first get uploaded to your artifact registry via the built-in Content Addressable Storage (CAS)
- type: ARTIFACT
name: rootfs
# Optional dockerfile
- type: ARTIFACT
name: dockerfile
optional: true
# SBOMs will be uploaded to the artifact registry and referenced in the attestation
# Both SBOM_CYCLONEDX_JSON and SBOM_SPDX_JSON are supported
- type: SBOM_CYCLONEDX_JSON
name: skynet-sbom
# CSAF_VEX and OPENVEX are supported
- type: OPENVEX
name: disclosure
# And static analysis reports in SARIF format
- type: SARIF
name: static-out
# STRING kind materials will be injected as simple keypairs
- type: STRING
name: build-ref

# Env vars we want the system to resolve and inject during attestation initialization
# Additional ones can be inherited from the specified runner context below
envAllowList:
- CUSTOM_VAR

# Enforce in what runner context the attestation must happen
# If not specified, the attestation crafting process is allowed to run anywhere
runner:
type: "GITHUB_ACTION"
36 changes: 36 additions & 0 deletions app/cli/pkg/action/testdata/contract_v2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
apiVersion: chainloop.dev/v1
kind: Contract
metadata:
name: test-contract-v2
description: Test contract in V2 format
annotations:
version: "1.0.0"
team: test-team
spec:
materials:
- type: CONTAINER_IMAGE
name: skynet-control-plane
output: true
annotations:
- name: component
value: control-plane
- type: ARTIFACT
name: rootfs
- type: SBOM_CYCLONEDX_JSON
name: skynet-sbom
- type: STRING
name: build-ref
optional: true
envAllowList:
- CUSTOM_VAR
- BUILD_NUMBER
runner:
type: GITHUB_ACTION
policies:
materials:
- ref: file://policy1.yaml
- ref: file://policy2.yaml
attestation:
- ref: file://attestation-policy.yaml
policyGroups:
- ref: file://testdata/policy_group.yaml
5 changes: 5 additions & 0 deletions app/cli/pkg/action/testdata/invalid_contract.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
this: is not
a: valid
contract: at all
missing_required_fields: true
invalid_structure: [1, 2, 3]
Loading
Loading