Skip to content

Commit 8280c8f

Browse files
authored
Merge pull request conforma#2678 from dheerajodha/EC-1309
EC-1309: Upload signed VSA to Rekor
2 parents 01dc64a + 2ba51bf commit 8280c8f

20 files changed

Lines changed: 2016 additions & 49 deletions

File tree

acceptance/acceptance_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import (
4040
"github.com/conforma/cli/acceptance/tekton"
4141
"github.com/conforma/cli/acceptance/testenv"
4242
"github.com/conforma/cli/acceptance/tuf"
43+
"github.com/conforma/cli/acceptance/vsa"
4344
"github.com/conforma/cli/acceptance/wiremock"
4445
)
4546

@@ -73,6 +74,7 @@ func initializeScenario(sc *godog.ScenarioContext) {
7374
pipeline.AddStepsTo(sc)
7475
conftest.AddStepsTo(sc)
7576
tuf.AddStepsTo(sc)
77+
vsa.AddStepsTo(sc)
7678

7779
sc.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
7880
logger, ctx := log.LoggerFor(ctx)

acceptance/cli/cli.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ type status struct {
5959
stderr *bytes.Buffer
6060
}
6161

62+
// Vars returns the variables map from the status
63+
func (s *status) Vars() map[string]string {
64+
return s.vars
65+
}
66+
6267
type key int
6368

6469
const (
@@ -199,6 +204,7 @@ func setupKeys(ctx context.Context, vars map[string]string, environment []string
199204
// to avail all public keys that have been generated for
200205
// substitution
201206
publicKeys := crypto.PublicKeysFrom(ctx)
207+
privateKeys := crypto.PrivateKeysFrom(ctx)
202208

203209
for name, publicKey := range publicKeys {
204210
key, err := os.CreateTemp("", "*.pub")
@@ -234,6 +240,31 @@ func setupKeys(ctx context.Context, vars map[string]string, environment []string
234240
vars[name+"_PUBLIC_KEY_XML"] = publicKeyXML.String()
235241
}
236242

243+
// Setup private keys for VSA functionality
244+
for name, privateKey := range privateKeys {
245+
privKey, err := os.CreateTemp("", "*.pem")
246+
if err != nil {
247+
return environment, vars, err
248+
}
249+
250+
if !testenv.Persisted(ctx) {
251+
testenv.Testing(ctx).Cleanup(func() {
252+
os.Remove(privKey.Name())
253+
})
254+
}
255+
256+
_, err = privKey.WriteString(privateKey)
257+
if err != nil {
258+
return environment, vars, err
259+
}
260+
err = privKey.Close()
261+
if err != nil {
262+
return environment, vars, err
263+
}
264+
265+
vars[name+"_PRIVATE_KEY"] = privKey.Name()
266+
}
267+
237268
return environment, vars, nil
238269
}
239270

@@ -675,6 +706,12 @@ func ecStatusFrom(ctx context.Context) (*status, error) {
675706
return status, nil
676707
}
677708

709+
// EcStatusFrom returns the command execution status from the context
710+
// This is an exported wrapper for ecStatusFrom to allow access from other packages
711+
func EcStatusFrom(ctx context.Context) (*status, error) {
712+
return ecStatusFrom(ctx)
713+
}
714+
678715
// logExecution logs the details of the execution and offers hits as how to
679716
// troubleshoot test failures by using persistent environment
680717
func logExecution(ctx context.Context) {

acceptance/crypto/keys.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,19 @@ func PublicKeysFrom(ctx context.Context) map[string]string {
117117
return ret
118118
}
119119

120+
// PrivateKeysFrom returns a map of all private keys encoded in PEM format
121+
// keyed by the name of the key
122+
func PrivateKeysFrom(ctx context.Context) map[string]string {
123+
keys := allKeysFrom(ctx)
124+
125+
ret := make(map[string]string, len(keys))
126+
for name, key := range keys {
127+
ret[name] = string(key.PrivateBytes)
128+
}
129+
130+
return ret
131+
}
132+
120133
// AddStepsTo adds Gherkin steps to the godog ScenarioContext
121134
func AddStepsTo(sc *godog.ScenarioContext) {
122135
sc.Step(`^a key pair named "([^"]*)"$`, GenerateKeyPairNamed)

acceptance/rekor/rekor.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,4 +361,65 @@ func AddStepsTo(sc *godog.ScenarioContext) {
361361
sc.Step(`^stub rekord running$`, stubRekordRunning)
362362
sc.Step(`^a valid Rekor entry for attestation of "([^"]*)"$`, RekorEntryForAttestation)
363363
sc.Step(`^a valid Rekor entry for image signature of "([^"]*)"$`, RekorEntryForImageSignature)
364+
sc.Step(`^VSA upload to Rekor should be expected$`, expectVSAUploadToRekor)
365+
sc.Step(`^VSA should be uploaded to Rekor successfully$`, vsaShouldBeUploadedToRekor)
366+
}
367+
368+
// expectVSAUploadToRekor creates WireMock stubs to expect VSA upload requests to Rekor
369+
func expectVSAUploadToRekor(ctx context.Context) error {
370+
// Create a stub that accepts any VSA upload request to /api/v1/log/entries
371+
// and returns a successful Rekor entry response using the same format as existing stubs
372+
entryUUID := "24296fb24b8ad77a12345678901234567890abcd"
373+
374+
// Create a minimal valid base64 envelope for the response
375+
// This represents a basic in-toto attestation structure
376+
envelope := map[string]interface{}{
377+
"payload": "eyJzdWJqZWN0IjpudWxsLCJwcmVkaWNhdGUiOnsidmVyaWZpZWRMZXZlbHMiOltdLCJkZXBlbmRlbmN5TGV2ZWxzIjp7fSwidGltZVZlcmlmaWVkIjoiMjAyNC0wMS0wMVQwMDowMDowMFoifX0=",
378+
"payloadType": "application/vnd.in-toto+json",
379+
"signatures": []map[string]string{
380+
{"sig": "MEUCIQDexample123456789", "keyid": ""},
381+
},
382+
}
383+
384+
envelopeBytes, _ := json.Marshal(envelope)
385+
envelopeB64 := base64.StdEncoding.EncodeToString(envelopeBytes)
386+
387+
response := map[string]interface{}{
388+
entryUUID: map[string]interface{}{
389+
"body": envelopeB64,
390+
"integratedTime": 1674049693,
391+
"logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d",
392+
"logIndex": 9876543,
393+
"verification": map[string]interface{}{
394+
"signedEntryTimestamp": "MEUCIQDexampleTimestamp123456789abcdefghijklmnopqrstuvwxyz==",
395+
},
396+
},
397+
}
398+
399+
responseBody, err := json.Marshal(response)
400+
if err != nil {
401+
return fmt.Errorf("failed to marshal Rekor response: %w", err)
402+
}
403+
404+
return wiremock.StubFor(ctx, wiremock.Post(wiremock.URLPathEqualTo("/api/v1/log/entries")).
405+
WillReturnResponse(wiremock.NewResponse().
406+
WithStatus(201).
407+
WithHeaders(map[string]string{
408+
"Content-Type": "application/json",
409+
"Location": fmt.Sprintf("/api/v1/log/entries/%s", entryUUID),
410+
}).
411+
WithBody(string(responseBody))))
412+
}
413+
414+
// vsaShouldBeUploadedToRekor verifies that VSA uploads to Rekor occurred successfully.
415+
// This relies on WireMock's automatic verification - if VSA uploads didn't happen or
416+
// didn't match our stub, WireMock will report unmatched requests/stubs in its After hook.
417+
func vsaShouldBeUploadedToRekor(ctx context.Context) error {
418+
if !wiremock.IsRunning(ctx) {
419+
return fmt.Errorf("WireMock is not running - cannot verify VSA uploads")
420+
}
421+
422+
// WireMock automatically verifies that our expectVSAUploadToRekor stub was matched
423+
// by actual VSA upload requests. No explicit verification needed here.
424+
return nil
364425
}

acceptance/vsa/vsa.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright The Conforma Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
// Package vsa provides step definitions for VSA (Verification Summary Attestation) functionality testing
18+
package vsa
19+
20+
import (
21+
"context"
22+
"fmt"
23+
"os"
24+
"path/filepath"
25+
26+
"github.com/cucumber/godog"
27+
28+
"github.com/conforma/cli/acceptance/cli"
29+
"github.com/conforma/cli/acceptance/log"
30+
)
31+
32+
// vsaEnvelopeFilesShouldExist checks that VSA envelope files exist in the specified directory
33+
func vsaEnvelopeFilesShouldExist(ctx context.Context, directory string) (context.Context, error) {
34+
logger, _ := log.LoggerFor(ctx)
35+
36+
// Get command status to access variables for expansion
37+
status, err := cli.EcStatusFrom(ctx)
38+
if err != nil {
39+
return ctx, err
40+
}
41+
42+
// Expand variables in the directory path using the vars map
43+
expandedDir := os.Expand(directory, func(key string) string {
44+
if value, ok := status.Vars()[key]; ok {
45+
return value
46+
}
47+
return ""
48+
})
49+
50+
logger.Infof("Checking for VSA envelope files in directory: %s", expandedDir)
51+
52+
// Check if directory exists
53+
if _, err := os.Stat(expandedDir); os.IsNotExist(err) {
54+
return ctx, fmt.Errorf("VSA output directory does not exist: %s", expandedDir)
55+
}
56+
57+
// Look for VSA envelope files (should have .json extension and vsa- prefix)
58+
files, err := filepath.Glob(filepath.Join(expandedDir, "vsa-*.json"))
59+
if err != nil {
60+
return ctx, fmt.Errorf("error searching for VSA envelope files: %v", err)
61+
}
62+
63+
if len(files) == 0 {
64+
return ctx, fmt.Errorf("no VSA envelope files found in directory: %s", expandedDir)
65+
}
66+
67+
for _, file := range files {
68+
logger.Infof("Found VSA envelope file: %s", file)
69+
}
70+
71+
logger.Infof("Successfully found VSA envelope files in: %s", expandedDir)
72+
73+
return ctx, nil
74+
}
75+
76+
// AddStepsTo adds VSA-related step definitions to the godog ScenarioContext
77+
func AddStepsTo(ctx *godog.ScenarioContext) {
78+
ctx.Step(`^VSA envelope files should exist in "([^"]*)"$`, vsaEnvelopeFilesShouldExist)
79+
}

cmd/validate/image.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
7878
workers int
7979
vsaEnabled bool
8080
vsaSigningKey string
81-
vsaUpload string
81+
vsaUpload []string
8282
}{
8383
strict: true,
8484
workers: 5,
@@ -492,10 +492,45 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
492492
}
493493

494494
// Process all VSAs using the service
495-
err = vsaService.ProcessAllVSAs(cmd.Context(), report, getGitURL, getDigest)
495+
vsaResult, err := vsaService.ProcessAllVSAs(cmd.Context(), report, getGitURL, getDigest)
496496
if err != nil {
497497
log.Errorf("Failed to process VSAs: %v", err)
498498
// Don't return error here, continue with the rest of the command
499+
} else {
500+
// Upload VSAs to configured storage backends
501+
if len(data.vsaUpload) > 0 {
502+
log.Infof("[VSA] Starting upload to %d storage backend(s)", len(data.vsaUpload))
503+
504+
// Upload component VSA envelopes
505+
for imageRef, envelopePath := range vsaResult.ComponentEnvelopes {
506+
uploadErr := vsa.UploadVSAEnvelope(cmd.Context(), envelopePath, data.vsaUpload, signer)
507+
if uploadErr != nil {
508+
log.Errorf("[VSA] Upload failed for component %s: %v", imageRef, uploadErr)
509+
} else {
510+
log.Infof("[VSA] Uploaded Component VSA")
511+
}
512+
}
513+
514+
// Upload snapshot VSA envelope if it exists
515+
if vsaResult.SnapshotEnvelope != "" {
516+
uploadErr := vsa.UploadVSAEnvelope(cmd.Context(), vsaResult.SnapshotEnvelope, data.vsaUpload, signer)
517+
if uploadErr != nil {
518+
log.Errorf("[VSA] Upload failed for snapshot: %v", uploadErr)
519+
} else {
520+
log.Infof("[VSA] Uploaded Snapshot VSA")
521+
}
522+
}
523+
} else {
524+
// No upload backends configured - inform user about next steps
525+
totalFiles := len(vsaResult.ComponentEnvelopes)
526+
if vsaResult.SnapshotEnvelope != "" {
527+
totalFiles++
528+
}
529+
530+
if totalFiles > 0 {
531+
log.Errorf("[VSA] VSA files generated but not uploaded (no --vsa-upload backends specified)")
532+
}
533+
}
499534
}
500535
}
501536

@@ -593,7 +628,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
593628

594629
cmd.Flags().BoolVar(&data.vsaEnabled, "vsa", false, "Generate a Verification Summary Attestation (VSA) for each validated image.")
595630
cmd.Flags().StringVar(&data.vsaSigningKey, "vsa-signing-key", "", "Path to the private key for signing the VSA.")
596-
cmd.Flags().StringVar(&data.vsaUpload, "vsa-upload", "oci", "Where to upload the VSA attestation: oci, rekor, none")
631+
cmd.Flags().StringSliceVar(&data.vsaUpload, "vsa-upload", nil, "Storage backends for VSA upload. Format: backend@url?param=value. Examples: rekor@https://rekor.sigstore.dev, local@./vsa-dir")
597632

598633
if len(data.input) > 0 || len(data.filePath) > 0 || len(data.images) > 0 {
599634
if err := cmd.MarkFlagRequired("image"); err != nil {

0 commit comments

Comments
 (0)