Skip to content

Commit 65f5226

Browse files
authored
Merge pull request #2650 from joejstuart/EC-1277
feat: implement VSA retrieval from Rekor using image digests
2 parents 68ac2b0 + 2172f2b commit 65f5226

File tree

3 files changed

+878
-0
lines changed

3 files changed

+878
-0
lines changed
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
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
18+
19+
import (
20+
"context"
21+
"encoding/base64"
22+
"encoding/hex"
23+
"fmt"
24+
"strings"
25+
26+
"github.com/sigstore/cosign/v2/cmd/cosign/cli/rekor"
27+
"github.com/sigstore/rekor/pkg/generated/client"
28+
"github.com/sigstore/rekor/pkg/generated/client/entries"
29+
"github.com/sigstore/rekor/pkg/generated/client/index"
30+
"github.com/sigstore/rekor/pkg/generated/models"
31+
log "github.com/sirupsen/logrus"
32+
)
33+
34+
// RekorVSARetriever implements VSARetriever using Rekor API
35+
type RekorVSARetriever struct {
36+
client RekorClient
37+
options RetrievalOptions
38+
}
39+
40+
// RekorClient defines the interface for Rekor client operations
41+
// This allows for easy mocking in tests
42+
type RekorClient interface {
43+
SearchIndex(ctx context.Context, query *models.SearchIndex) ([]models.LogEntryAnon, error)
44+
SearchLogQuery(ctx context.Context, query *models.SearchLogQuery) ([]models.LogEntryAnon, error)
45+
GetLogEntryByIndex(ctx context.Context, index int64) (*models.LogEntryAnon, error)
46+
GetLogEntryByUUID(ctx context.Context, uuid string) (*models.LogEntryAnon, error)
47+
}
48+
49+
// NewRekorVSARetriever creates a new Rekor-based VSA retriever
50+
func NewRekorVSARetriever(opts RetrievalOptions) (*RekorVSARetriever, error) {
51+
if opts.URL == "" {
52+
return nil, fmt.Errorf("RekorURL is required")
53+
}
54+
55+
client, err := rekor.NewClient(opts.URL)
56+
if err != nil {
57+
return nil, fmt.Errorf("failed to create Rekor client: %w", err)
58+
}
59+
60+
return &RekorVSARetriever{
61+
client: &rekorClient{client: client},
62+
options: opts,
63+
}, nil
64+
}
65+
66+
// NewRekorVSARetrieverWithClient creates a new Rekor-based VSA retriever with a custom client
67+
// This is primarily for testing purposes
68+
func NewRekorVSARetrieverWithClient(client RekorClient, opts RetrievalOptions) *RekorVSARetriever {
69+
log.Debugf("Creating RekorVSARetriever with custom client")
70+
return &RekorVSARetriever{
71+
client: client,
72+
options: opts,
73+
}
74+
}
75+
76+
// RetrieveVSA implements VSARetriever.RetrieveVSA
77+
func (r *RekorVSARetriever) RetrieveVSA(ctx context.Context, imageDigest string) ([]VSARecord, error) {
78+
if imageDigest == "" {
79+
return nil, fmt.Errorf("image digest cannot be empty")
80+
}
81+
82+
// Validate image digest format
83+
if !isValidImageDigest(imageDigest) {
84+
return nil, fmt.Errorf("invalid image digest format: %s", imageDigest)
85+
}
86+
87+
// Create context with timeout if specified
88+
if r.options.Timeout > 0 {
89+
var cancel context.CancelFunc
90+
ctx, cancel = context.WithTimeout(ctx, r.options.Timeout)
91+
defer cancel()
92+
}
93+
94+
log.Debugf("Retrieving VSA records for image digest: %s", imageDigest)
95+
96+
// Search for entries containing the image digest
97+
entries, err := r.searchForImageDigest(ctx, imageDigest)
98+
if err != nil {
99+
return nil, fmt.Errorf("failed to search Rekor for image digest: %w", err)
100+
}
101+
102+
log.Debugf("RetrieveVSA: search returned %d entries", len(entries))
103+
104+
var vsaRecords []VSARecord
105+
106+
// Process each entry to find VSA records
107+
for _, entry := range entries {
108+
log.Debugf("Processing entry: LogIndex=%v, LogID=%v", entry.LogIndex, entry.LogID)
109+
if isVSARecord(entry, imageDigest) {
110+
vsaRecord, err := r.parseVSARecord(entry)
111+
if err != nil {
112+
log.Warnf("Failed to parse VSA record: %v", err)
113+
continue
114+
}
115+
vsaRecords = append(vsaRecords, vsaRecord)
116+
log.Debugf("Added VSA record: LogIndex=%d, LogID=%s", vsaRecord.LogIndex, vsaRecord.LogID)
117+
} else {
118+
log.Debugf("Entry is not a VSA record")
119+
}
120+
}
121+
122+
log.Debugf("Found %d VSA records for image digest: %s", len(vsaRecords), imageDigest)
123+
return vsaRecords, nil
124+
}
125+
126+
// searchForImageDigest searches Rekor for entries containing the given image digest
127+
func (r *RekorVSARetriever) searchForImageDigest(ctx context.Context, imageDigest string) ([]models.LogEntryAnon, error) {
128+
log.Debugf("searchForImageDigest called with imageDigest: %s", imageDigest)
129+
130+
// Create search query using the search index API
131+
query := &models.SearchIndex{
132+
Hash: imageDigest,
133+
}
134+
135+
log.Debugf("Calling client.SearchIndex")
136+
entries, err := r.client.SearchIndex(ctx, query)
137+
if err != nil {
138+
log.Debugf("SearchIndex returned error: %v", err)
139+
return nil, fmt.Errorf("failed to search Rekor index: %w", err)
140+
}
141+
142+
log.Debugf("Search returned %d entries", len(entries))
143+
144+
// The search index should return only entries containing our image digest
145+
// No need for additional filtering
146+
return entries, nil
147+
}
148+
149+
// isValidImageDigest validates the format of an image digest
150+
func isValidImageDigest(digest string) bool {
151+
// Image digest should be in format: algorithm:hash
152+
parts := strings.Split(digest, ":")
153+
if len(parts) != 2 {
154+
return false
155+
}
156+
157+
// Check if algorithm is supported (sha256, sha512, etc.)
158+
algorithm := parts[0]
159+
if algorithm != "sha256" && algorithm != "sha512" {
160+
return false
161+
}
162+
163+
// Check if hash is valid hex
164+
hash := parts[1]
165+
if len(hash) == 0 {
166+
return false
167+
}
168+
169+
// Validate hex format
170+
_, err := hex.DecodeString(hash)
171+
return err == nil
172+
}
173+
174+
// isVSARecord determines if a Rekor entry contains a VSA record for the given image digest
175+
func isVSARecord(entry models.LogEntryAnon, imageDigest string) bool {
176+
// Check if entry has attestation data
177+
if entry.Attestation == nil || entry.Attestation.Data == nil {
178+
log.Debugf("Entry has no attestation data")
179+
return false
180+
}
181+
182+
// Decode the attestation data to check for VSA predicate type
183+
attestationData, err := base64.StdEncoding.DecodeString(string(entry.Attestation.Data))
184+
if err != nil {
185+
log.Debugf("Failed to decode attestation data: %v", err)
186+
return false
187+
}
188+
189+
// Check if the attestation contains the VSA predicate type
190+
attestationStr := string(attestationData)
191+
vsaPredicateType := "https://conforma.dev/verification_summary/v1"
192+
193+
if strings.Contains(attestationStr, vsaPredicateType) {
194+
log.Debugf("Found VSA predicate type in attestation")
195+
return true
196+
}
197+
198+
log.Debugf("Attestation does not contain VSA predicate type")
199+
return false
200+
}
201+
202+
// parseVSARecord converts a Rekor log entry to a VSARecord
203+
func (r *RekorVSARetriever) parseVSARecord(entry models.LogEntryAnon) (VSARecord, error) {
204+
record := VSARecord{
205+
Attestation: entry.Attestation,
206+
Verification: entry.Verification,
207+
}
208+
209+
// Extract log index
210+
if entry.LogIndex != nil {
211+
record.LogIndex = *entry.LogIndex
212+
}
213+
214+
// Extract log ID
215+
if entry.LogID != nil {
216+
record.LogID = *entry.LogID
217+
}
218+
219+
// Extract integrated time
220+
if entry.IntegratedTime != nil {
221+
record.IntegratedTime = *entry.IntegratedTime
222+
}
223+
224+
// Extract body
225+
if entry.Body != nil {
226+
if bodyStr, ok := entry.Body.(string); ok {
227+
record.Body = bodyStr
228+
}
229+
}
230+
231+
return record, nil
232+
}
233+
234+
// rekorClient wraps the actual Rekor client to implement our interface
235+
type rekorClient struct {
236+
client *client.Rekor
237+
}
238+
239+
func (rc *rekorClient) SearchIndex(ctx context.Context, query *models.SearchIndex) ([]models.LogEntryAnon, error) {
240+
params := &index.SearchIndexParams{
241+
Context: ctx,
242+
Query: query,
243+
}
244+
245+
result, err := rc.client.Index.SearchIndex(params)
246+
if err != nil {
247+
return nil, err
248+
}
249+
250+
// SearchIndex returns a list of UUIDs, we need to fetch the full entries
251+
var entries []models.LogEntryAnon
252+
253+
if result.Payload != nil {
254+
for _, uuid := range result.Payload {
255+
// Fetch the full log entry for each UUID
256+
entry, err := rc.GetLogEntryByUUID(ctx, uuid)
257+
if err != nil {
258+
log.Debugf("Failed to fetch log entry for UUID %s: %v", uuid, err)
259+
continue
260+
}
261+
if entry != nil {
262+
entries = append(entries, *entry)
263+
}
264+
}
265+
}
266+
267+
log.Debugf("SearchIndex returned %d UUIDs, fetched %d full entries", len(result.Payload), len(entries))
268+
return entries, nil
269+
}
270+
271+
func (rc *rekorClient) SearchLogQuery(ctx context.Context, query *models.SearchLogQuery) ([]models.LogEntryAnon, error) {
272+
params := &entries.SearchLogQueryParams{
273+
Context: ctx,
274+
Entry: query,
275+
}
276+
277+
result, err := rc.client.Entries.SearchLogQuery(params)
278+
if err != nil {
279+
return nil, err
280+
}
281+
282+
var entries []models.LogEntryAnon
283+
if result.Payload != nil {
284+
for _, logEntryMap := range result.Payload {
285+
// Each logEntryMap is a models.LogEntry (map[string]models.LogEntryAnon)
286+
// Extract all LogEntryAnon values from the map
287+
for _, entry := range logEntryMap {
288+
entries = append(entries, entry)
289+
}
290+
}
291+
}
292+
293+
return entries, nil
294+
}
295+
296+
func (rc *rekorClient) GetLogEntryByIndex(ctx context.Context, index int64) (*models.LogEntryAnon, error) {
297+
params := &entries.GetLogEntryByIndexParams{
298+
Context: ctx,
299+
LogIndex: index,
300+
}
301+
302+
result, err := rc.client.Entries.GetLogEntryByIndex(params)
303+
if err != nil {
304+
return nil, err
305+
}
306+
307+
// Convert the result to the expected format
308+
// GetLogEntryByIndex returns a map[string]models.LogEntryAnon
309+
if result.Payload != nil {
310+
// The payload is a map where the key is the UUID and value is the log entry
311+
// We need to find the entry by index, but the map is keyed by UUID
312+
// For now, return the first entry found (this might need refinement)
313+
for _, entry := range result.Payload {
314+
return &entry, nil
315+
}
316+
}
317+
318+
return nil, fmt.Errorf("log entry not found for index: %d", index)
319+
}
320+
321+
func (rc *rekorClient) GetLogEntryByUUID(ctx context.Context, uuid string) (*models.LogEntryAnon, error) {
322+
params := &entries.GetLogEntryByUUIDParams{
323+
Context: ctx,
324+
EntryUUID: uuid,
325+
}
326+
327+
result, err := rc.client.Entries.GetLogEntryByUUID(params)
328+
if err != nil {
329+
return nil, err
330+
}
331+
332+
// Convert the result to the expected format
333+
// GetLogEntryByUUID returns a map[string]models.LogEntryAnon
334+
if result.Payload != nil {
335+
// The payload is a map where the key is the UUID and value is the log entry
336+
if entry, exists := result.Payload[uuid]; exists {
337+
return &entry, nil
338+
}
339+
}
340+
341+
return nil, fmt.Errorf("log entry not found for UUID: %s", uuid)
342+
}

internal/validate/vsa/retrieval.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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
18+
19+
import (
20+
"context"
21+
"time"
22+
23+
"github.com/sigstore/rekor/pkg/generated/models"
24+
)
25+
26+
// VSARetriever defines the interface for retrieving VSA records from Rekor
27+
type VSARetriever interface {
28+
// RetrieveVSA retrieves VSA records for a given image digest
29+
RetrieveVSA(ctx context.Context, imageDigest string) ([]VSARecord, error)
30+
}
31+
32+
// VSARecord represents a VSA record retrieved from Rekor
33+
type VSARecord struct {
34+
LogIndex int64 `json:"logIndex"`
35+
LogID string `json:"logID"`
36+
IntegratedTime int64 `json:"integratedTime"`
37+
UUID string `json:"uuid"`
38+
Body string `json:"body"`
39+
Attestation *models.LogEntryAnonAttestation `json:"attestation,omitempty"`
40+
Verification *models.LogEntryAnonVerification `json:"verification,omitempty"`
41+
}
42+
43+
// RetrievalOptions configures VSA retrieval behavior
44+
type RetrievalOptions struct {
45+
URL string
46+
Timeout time.Duration
47+
}
48+
49+
// DefaultRetrievalOptions returns default options for VSA retrieval
50+
func DefaultRetrievalOptions() RetrievalOptions {
51+
return RetrievalOptions{
52+
Timeout: 30 * time.Second,
53+
}
54+
}

0 commit comments

Comments
 (0)