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
13 changes: 10 additions & 3 deletions app/cli/cmd/attestation_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (

schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

Expand Down Expand Up @@ -71,9 +72,15 @@ func newAttestationPushCmd() *cobra.Command {
return fmt.Errorf("getting executable information: %w", err)
}
a, err := action.NewAttestationPush(&action.AttestationPushOpts{
ActionsOpts: ActionOpts, KeyPath: pkPath, BundlePath: bundle,
CLIVersion: info.Version, CLIDigest: info.Digest,
LocalStatePath: attestationLocalStatePath,
ActionsOpts: ActionOpts,
KeyPath: pkPath,
BundlePath: bundle,
CLIVersion: info.Version,
CLIDigest: info.Digest,
CASURI: viper.GetString(confOptions.CASAPI.viperKey),
CASCAPath: viper.GetString(confOptions.CASCA.viperKey),
ConnectionInsecure: apiInsecure(),
LocalStatePath: attestationLocalStatePath,
SignServerOpts: &action.SignServerOpts{
CAPath: signServerCAPath,
AuthClientCertPath: signServerAuthCertPath,
Expand Down
87 changes: 76 additions & 11 deletions app/cli/pkg/action/attestation_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
package action

import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"os"
Expand All @@ -25,9 +27,13 @@ import (
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer"
crChainloop "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
"github.com/chainloop-dev/chainloop/pkg/attestation/signer"
"github.com/chainloop-dev/chainloop/pkg/casclient"
"github.com/chainloop-dev/chainloop/pkg/policies"
intoto "github.com/in-toto/attestation/go/v1"
"github.com/secure-systems-lab/go-securesystemslib/dsse"
protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
"google.golang.org/grpc"
Expand All @@ -38,9 +44,11 @@ import (
type AttestationPushOpts struct {
*ActionsOpts
KeyPath, CLIVersion, CLIDigest, BundlePath string

LocalStatePath string
SignServerOpts *SignServerOpts
CASURI string
CASCAPath string
ConnectionInsecure bool
LocalStatePath string
SignServerOpts *SignServerOpts
}

// SignServerOpts holds SignServer integration options
Expand All @@ -60,6 +68,9 @@ type AttestationResult struct {
type AttestationPush struct {
*ActionsOpts
keyPath, cliVersion, cliDigest, bundlePath string
casURI string
casCAPath string
connectionInsecure bool
localStatePath string
signServerOpts *SignServerOpts
*newCrafterOpts
Expand All @@ -68,14 +79,17 @@ type AttestationPush struct {
func NewAttestationPush(cfg *AttestationPushOpts) (*AttestationPush, error) {
opts := []crafter.NewOpt{crafter.WithLogger(&cfg.Logger), crafter.WithAuthRawToken(cfg.AuthTokenRaw)}
return &AttestationPush{
ActionsOpts: cfg.ActionsOpts,
keyPath: cfg.KeyPath,
cliVersion: cfg.CLIVersion,
cliDigest: cfg.CLIDigest,
bundlePath: cfg.BundlePath,
signServerOpts: cfg.SignServerOpts,
localStatePath: cfg.LocalStatePath,
newCrafterOpts: &newCrafterOpts{cpConnection: cfg.CPConnection, opts: opts},
ActionsOpts: cfg.ActionsOpts,
keyPath: cfg.KeyPath,
cliVersion: cfg.CLIVersion,
cliDigest: cfg.CLIDigest,
bundlePath: cfg.BundlePath,
casURI: cfg.CASURI,
casCAPath: cfg.CASCAPath,
connectionInsecure: cfg.ConnectionInsecure,
signServerOpts: cfg.SignServerOpts,
localStatePath: cfg.LocalStatePath,
newCrafterOpts: &newCrafterOpts{cpConnection: cfg.CPConnection, opts: opts},
}, nil
}

Expand Down Expand Up @@ -205,6 +219,29 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru
// Update the status result with the definitive push-phase evaluation against the final statement
attestationStatus.PolicyEvaluations, attestationStatus.HasPolicyViolations = getPolicyEvaluations(crafter)

// Upload policy evaluations bundle to CAS when an external backend is available
if evaluations := crafter.CraftingState.GetAttestation().GetPolicyEvaluations(); !crafter.CraftingState.DryRun && len(evaluations) > 0 {
casBackend := &casclient.CASBackend{Name: "not-set"}
workflowRunID := crafter.CraftingState.GetAttestation().GetWorkflow().GetWorkflowRunId()
_, connectionCloserFn, getCASErr := getCASBackend(ctx, attClient, workflowRunID, action.casCAPath, action.casURI, action.connectionInsecure, action.Logger, casBackend)
if connectionCloserFn != nil {
// nolint: errcheck
defer connectionCloserFn()
}

if getCASErr != nil || casBackend.Uploader == nil {
action.Logger.Debug().Msg("CAS backend is inline, skipping policy evaluations bundle upload")

@cubic-dev-ai cubic-dev-ai Bot Mar 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The debug message is too specific for the condition and can misreport backend errors as "inline" mode, making operational debugging harder.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/cli/pkg/action/attestation_push.go, line 233:

<comment>The debug message is too specific for the condition and can misreport backend errors as "inline" mode, making operational debugging harder.</comment>

<file context>
@@ -230,12 +230,13 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru
 
 		if getCASErr != nil || casBackend.Uploader == nil {
-			action.Logger.Debug().Msg("CAS backend not available, skipping policy evaluations bundle upload")
+			action.Logger.Debug().Msg("CAS backend is inline, skipping policy evaluations bundle upload")
 		} else {
 			ref, uploadErr := uploadPolicyEvaluationsBundle(ctx, evaluations, casBackend.Uploader)
</file context>
Suggested change
action.Logger.Debug().Msg("CAS backend is inline, skipping policy evaluations bundle upload")
action.Logger.Debug().Err(getCASErr).Msg("CAS backend not available, skipping policy evaluations bundle upload")
Fix with Cubic

} else {
ref, uploadErr := uploadPolicyEvaluationsBundle(ctx, evaluations, casBackend.Uploader)
if uploadErr != nil {
return nil, fmt.Errorf("uploading policy evaluations bundle to CAS: %w", uploadErr)

@cubic-dev-ai cubic-dev-ai Bot Mar 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: CAS upload failure is now treated as fatal, which breaks the intended best-effort behavior for policy-evaluations CAS storage during attestation push.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/cli/pkg/action/attestation_push.go, line 237:

<comment>CAS upload failure is now treated as fatal, which breaks the intended best-effort behavior for policy-evaluations CAS storage during attestation push.</comment>

<file context>
@@ -230,12 +230,13 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru
 			if uploadErr != nil {
-				action.Logger.Warn().Err(uploadErr).Msg("failed to upload policy evaluations bundle to CAS")
-			} else if ref != nil {
+				return nil, fmt.Errorf("uploading policy evaluations bundle to CAS: %w", uploadErr)
+			}
+			if ref != nil {
</file context>
Suggested change
return nil, fmt.Errorf("uploading policy evaluations bundle to CAS: %w", uploadErr)
action.Logger.Warn().Err(uploadErr).Msg("failed to upload policy evaluations bundle to CAS")
Fix with Cubic

}
if ref != nil {
renderer.SetPolicyEvaluationsRef(ref)
}
}
}

// render final attestation with all the evaluated policies inside
envelope, bundle, err := renderer.Render(ctx)
if err != nil {
Expand Down Expand Up @@ -318,3 +355,31 @@ func decodeEnvelope(rawEnvelope []byte) (*dsse.Envelope, error) {

return envelope, nil
}

// uploadPolicyEvaluationsBundle serializes policy evaluations as a protobuf bundle,
// uploads to CAS, and returns a ResourceDescriptor referencing the uploaded object.
// Returns (nil, nil) when there are no evaluations or no uploader.
func uploadPolicyEvaluationsBundle(ctx context.Context, evaluations []*v1.PolicyEvaluation, uploader casclient.Uploader) (*intoto.ResourceDescriptor, error) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Do we have any code already for uploading to CAS that we can reuse, maybe from the crafter material part? I want to make sure that we don't try reimplement the same logic. Today we upload AI_CONFIG materials, etc

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The existing uploadAndCraft() in pkg/attestation/crafter/materials/materials.go is material-specific — it takes a file path, produces an Attestation_Material proto, and handles inline fallback, size checks, skip-upload flags, and material annotations. None of that applies here.

The actual shared primitive is Uploader.Upload(ctx, reader, filename, digest) from the casclient package, which is what we use directly. The uploadPolicyEvaluationsBundle function adds only the protobuf serialization + SHA256 + ResourceDescriptor construction, all specific to this use case.

Key difference from materials: when no CAS backend is available, materials fall back to inline storage (InlineCas = true). Here we intentionally skip (the existing inline policyEvaluations predicate field already serves as the fallback, per the spec).

if len(evaluations) == 0 || uploader == nil {
return nil, nil
}

bundle := &v1.PolicyEvaluationBundle{Evaluations: evaluations}
data, err := protojson.Marshal(bundle)
if err != nil {
return nil, fmt.Errorf("marshaling policy evaluation bundle: %w", err)
}

hexDigest := fmt.Sprintf("%x", sha256.Sum256(data))
digest := fmt.Sprintf("sha256:%s", hexDigest)

if _, err := uploader.Upload(ctx, bytes.NewReader(data), "policy-evaluations.json", digest); err != nil {
return nil, fmt.Errorf("uploading policy evaluation bundle: %w", err)
}

return &intoto.ResourceDescriptor{
Name: "policy-evaluations",
Digest: map[string]string{"sha256": hexDigest},
MediaType: crChainloop.PolicyEvaluationsBundleMediaType,
}, nil
}
122 changes: 122 additions & 0 deletions app/cli/pkg/action/attestation_push_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//
// 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 action

import (
"context"
"crypto/sha256"
"fmt"
"testing"

v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
"github.com/chainloop-dev/chainloop/pkg/casclient"
casclientmock "github.com/chainloop-dev/chainloop/pkg/casclient/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/encoding/protojson"
)

func TestUploadPolicyEvaluationsBundle(t *testing.T) {
testCases := []struct {
name string
evaluations []*v1.PolicyEvaluation
uploader func(t *testing.T) casclient.Uploader
wantRef bool
wantErr bool
}{
{
name: "nil evaluations returns nil ref",
evaluations: nil,
wantRef: false,
},
{
name: "empty evaluations returns nil ref",
evaluations: []*v1.PolicyEvaluation{},
wantRef: false,
},
{
name: "nil uploader returns nil ref",
evaluations: []*v1.PolicyEvaluation{
{Name: "test-policy"},
},
wantRef: false,
},
{
name: "successful upload returns ref with correct digest and media type",
evaluations: []*v1.PolicyEvaluation{
{Name: "test-policy", MaterialName: "sbom"},
},
uploader: func(t *testing.T) casclient.Uploader {
t.Helper()
m := casclientmock.NewUploader(t)
m.On("Upload", mock.Anything, mock.Anything, "policy-evaluations.json", mock.MatchedBy(func(digest string) bool {
return len(digest) > 7 && digest[:7] == "sha256:"
})).Return(&casclient.UpDownStatus{Filename: "policy-evaluations.json"}, nil)
return m
},
wantRef: true,
},
{
name: "upload failure returns error",
evaluations: []*v1.PolicyEvaluation{
{Name: "test-policy"},
},
uploader: func(t *testing.T) casclient.Uploader {
t.Helper()
m := casclientmock.NewUploader(t)
m.On("Upload", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf("upload failed"))
return m
},
wantErr: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var uploader casclient.Uploader
if tc.uploader != nil {
uploader = tc.uploader(t)
}

ref, err := uploadPolicyEvaluationsBundle(context.Background(), tc.evaluations, uploader)
if tc.wantErr {
require.Error(t, err)
return
}

require.NoError(t, err)

if !tc.wantRef {
assert.Nil(t, ref)
return
}

require.NotNil(t, ref)
assert.Equal(t, "policy-evaluations", ref.Name)
assert.Equal(t, chainloop.PolicyEvaluationsBundleMediaType, ref.MediaType)
assert.NotEmpty(t, ref.Digest["sha256"])

// Verify the digest matches what we'd expect from serializing the bundle
bundle := &v1.PolicyEvaluationBundle{Evaluations: tc.evaluations}
data, err := protojson.Marshal(bundle)
require.NoError(t, err)
expectedDigest := fmt.Sprintf("%x", sha256.Sum256(data))
assert.Equal(t, expectedDigest, ref.Digest["sha256"])
})
}
}
69 changes: 69 additions & 0 deletions app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions app/controlplane/pkg/biz/casmapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,5 +240,15 @@ func (uc *CASMappingUseCase) LookupDigestsInAttestation(att *dsse.Envelope, dige
}
}

// Include the policy evaluations bundle if stored in CAS
if ref := predicate.GetPolicyEvaluationsRef(); ref != nil {
if d, ok := ref.Digest["sha256"]; ok {
references = append(references, &CASMappingLookupRef{
Name: ref.Name,
Digest: fmt.Sprintf("sha256:%s", d),
})
}
}

return references, nil
}
Loading
Loading