-
Notifications
You must be signed in to change notification settings - Fork 53
feat: store policy evaluations in CAS during attestation push #2949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ba04466
9196dca
ec3d8ec
f863dbe
04844a9
189cfd3
dbffaca
d0e5434
cac5f24
fffe853
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -16,7 +16,9 @@ | |||||
| package action | ||||||
|
|
||||||
| import ( | ||||||
| "bytes" | ||||||
| "context" | ||||||
| "crypto/sha256" | ||||||
| "encoding/json" | ||||||
| "fmt" | ||||||
| "os" | ||||||
|
|
@@ -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" | ||||||
|
|
@@ -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 | ||||||
|
|
@@ -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 | ||||||
|
|
@@ -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 | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -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") | ||||||
| } else { | ||||||
| ref, uploadErr := uploadPolicyEvaluationsBundle(ctx, evaluations, casBackend.Uploader) | ||||||
| if uploadErr != nil { | ||||||
| return nil, fmt.Errorf("uploading policy evaluations bundle to CAS: %w", uploadErr) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||
| } | ||||||
| if ref != nil { | ||||||
| renderer.SetPolicyEvaluationsRef(ref) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| // render final attestation with all the evaluated policies inside | ||||||
| envelope, bundle, err := renderer.Render(ctx) | ||||||
| if err != nil { | ||||||
|
|
@@ -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) { | ||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The existing The actual shared primitive is Key difference from materials: when no CAS backend is available, materials fall back to inline storage ( |
||||||
| 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 | ||||||
| } | ||||||
| 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"]) | ||
| }) | ||
| } | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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