Skip to content

Commit d476762

Browse files
committed
Consolidate vsa processing.
- remove signing in cmd/validate/image.go - improve testability by creating interfaces for writer and generator.
1 parent 8c8fa20 commit d476762

File tree

3 files changed

+276
-195
lines changed

3 files changed

+276
-195
lines changed

cmd/validate/image.go

Lines changed: 66 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@ import (
3232
"github.com/spf13/cobra"
3333
extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
3434

35-
"github.com/enterprise-contract/ec-cli/internal/applicationsnapshot"
36-
"github.com/enterprise-contract/ec-cli/internal/evaluator"
37-
"github.com/enterprise-contract/ec-cli/internal/format"
38-
"github.com/enterprise-contract/ec-cli/internal/output"
39-
"github.com/enterprise-contract/ec-cli/internal/policy"
40-
"github.com/enterprise-contract/ec-cli/internal/policy/source"
41-
"github.com/enterprise-contract/ec-cli/internal/utils"
42-
validate_utils "github.com/enterprise-contract/ec-cli/internal/validate"
43-
"github.com/enterprise-contract/ec-cli/internal/validate/vsa"
35+
"github.com/conforma/cli/internal/applicationsnapshot"
36+
"github.com/conforma/cli/internal/evaluator"
37+
"github.com/conforma/cli/internal/format"
38+
"github.com/conforma/cli/internal/output"
39+
"github.com/conforma/cli/internal/policy"
40+
"github.com/conforma/cli/internal/policy/source"
41+
"github.com/conforma/cli/internal/utils"
42+
validate_utils "github.com/conforma/cli/internal/validate"
43+
"github.com/conforma/cli/internal/validate/vsa"
4444
)
4545

4646
type imageValidationFunc func(context.Context, app.SnapshotComponent, *app.SnapshotSpec, policy.Policy, []evaluator.Evaluator, bool) (*output.Output, error)
@@ -249,12 +249,12 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
249249
if src.RuleData != nil {
250250
rule_data_raw, err = src.RuleData.MarshalJSON()
251251
if err != nil {
252-
allErrors = errors.Join(allErrors, fmt.Errorf("Unable to parse ruledata to raw data"))
252+
allErrors = errors.Join(allErrors, fmt.Errorf("unable to parse ruledata to raw data"))
253253
continue
254254
}
255255
err = json.Unmarshal(rule_data_raw, &unmarshaled)
256256
if err != nil {
257-
allErrors = errors.Join(allErrors, fmt.Errorf("Unable to parse ruledata into standard JSON object"))
257+
allErrors = errors.Join(allErrors, fmt.Errorf("unable to parse ruledata into standard JSON object"))
258258
continue
259259
}
260260
} else {
@@ -264,30 +264,30 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
264264
for j := range data.extraRuleData {
265265
parts := strings.SplitN(data.extraRuleData[j], "=", 2)
266266
if len(parts) < 2 {
267-
allErrors = errors.Join(allErrors, fmt.Errorf("Incorrect syntax for --extra-rule-data %d", j))
267+
allErrors = errors.Join(allErrors, fmt.Errorf("incorrect syntax for --extra-rule-data %d", j))
268268
continue
269269
}
270270
extraRuleDataPolicyConfig, err := validate_utils.GetPolicyConfig(ctx, parts[1])
271271
if err != nil {
272-
allErrors = errors.Join(allErrors, fmt.Errorf("Unable to load data from extraRuleData: %s", err.Error()))
272+
allErrors = errors.Join(allErrors, fmt.Errorf("unable to load data from extraRuleData: %s", err.Error()))
273273
continue
274274
}
275275
unmarshaled[parts[0]] = extraRuleDataPolicyConfig
276276
}
277277
rule_data_raw, err = json.Marshal(unmarshaled)
278278
if err != nil {
279-
allErrors = errors.Join(allErrors, fmt.Errorf("Unable to parse updated ruledata: %s", err.Error()))
279+
allErrors = errors.Join(allErrors, fmt.Errorf("unable to parse updated ruledata: %s", err.Error()))
280280
continue
281281
}
282282

283283
if rule_data_raw == nil {
284-
allErrors = errors.Join(allErrors, fmt.Errorf("Invalid rule data JSON"))
284+
allErrors = errors.Join(allErrors, fmt.Errorf("invalid rule data JSON"))
285285
continue
286286
}
287287

288288
err = sources[i].RuleData.UnmarshalJSON(rule_data_raw)
289289
if err != nil {
290-
allErrors = errors.Join(allErrors, fmt.Errorf("Unable to marshal updated JSON: %s", err.Error()))
290+
allErrors = errors.Join(allErrors, fmt.Errorf("unable to marshal updated JSON: %s", err.Error()))
291291
continue
292292
}
293293
}
@@ -458,58 +458,11 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
458458
}
459459

460460
if data.vsaEnabled {
461-
// For each validated component, generate and write a VSA
462-
vsaPkgOpts := vsa.Options{
463-
OutputDir: "./", // TODO: Make configurable or use temp dir
464-
SigningKeyPath: data.vsaSigningKey,
465-
}
466461
for _, comp := range components {
467-
// VSA generation
468-
log.Debugf("[VSA] Generating predicate for image: %s", comp.ContainerImage)
469-
pred, err := vsa.GeneratePredicate(cmd.Context(), report, comp, vsaPkgOpts)
470-
if err != nil {
471-
log.Errorf("[VSA] Failed to generate predicate for image %s: %v", comp.ContainerImage, err)
472-
continue
473-
}
474-
log.Debugf("[VSA] Predicate generated for image: %s", comp.ContainerImage)
475-
476-
vsaPath := fmt.Sprintf("%s.vsa.json", comp.ContainerImage) // TODO: sanitize filename
477-
log.Debugf("[VSA] Writing VSA to %s", vsaPath)
478-
err = vsa.WriteVSA(pred, vsaPath)
479-
if err != nil {
480-
log.Errorf("[VSA] Failed to write VSA for image %s: %v", comp.ContainerImage, err)
462+
if err := processVSA(cmd.Context(), report, comp); err != nil {
463+
log.Errorf("[VSA] Error processing VSA for image %s: %v", comp.ContainerImage, err)
481464
continue
482465
}
483-
log.Debugf("[VSA] VSA written to %s", vsaPath)
484-
485-
if data.vsaSigningKey != "" {
486-
log.Debugf("[VSA] Signing VSA for image: %s", comp.ContainerImage)
487-
att, err := vsa.SignVSA(cmd.Context(), vsaPath, data.vsaSigningKey, comp.ContainerImage)
488-
if err != nil {
489-
log.Errorf("[VSA] Failed to sign VSA for image %s: %v", comp.ContainerImage, err)
490-
continue
491-
}
492-
log.Infof("[VSA] Signed attestation for %s", comp.ContainerImage)
493-
var uploader vsa.AttestationUploader
494-
switch data.vsaUpload {
495-
case "oci":
496-
uploader = vsa.OCIUploader
497-
case "rekor":
498-
uploader = vsa.RekorUploader
499-
case "none":
500-
uploader = vsa.NoopUploader
501-
default:
502-
log.Errorf("[VSA] Unknown vsa-upload type: %s", data.vsaUpload)
503-
continue
504-
}
505-
uploadResult, err := uploader(cmd.Context(), att, comp.ContainerImage)
506-
if err != nil {
507-
log.Errorf("[VSA] Failed to upload VSA attestation for image %s: %v", comp.ContainerImage, err)
508-
continue
509-
}
510-
log.Infof("[VSA] VSA attestation uploaded for %s: %s", comp.ContainerImage, uploadResult)
511-
}
512-
// Rekor upload is skipped for now
513466
}
514467
}
515468
if data.strict && !report.Success {
@@ -627,3 +580,51 @@ func containsOutput(data []string, value string) bool {
627580
}
628581
return false
629582
}
583+
584+
// PredicateGenerator defines the interface for generating VSA predicates
585+
type PredicateGenerator interface {
586+
GeneratePredicate(ctx context.Context, report applicationsnapshot.Report, comp applicationsnapshot.Component) (*vsa.Predicate, error)
587+
}
588+
589+
// VSAWriter defines the interface for writing VSA files
590+
type VSAWriter interface {
591+
WriteVSA(predicate *vsa.Predicate) (string, error)
592+
}
593+
594+
// generateAndWriteVSA generates a VSA predicate and writes it to a file
595+
func generateAndWriteVSA(
596+
ctx context.Context,
597+
report applicationsnapshot.Report,
598+
comp applicationsnapshot.Component,
599+
generator PredicateGenerator,
600+
writer VSAWriter,
601+
) (string, error) {
602+
log.Debugf("[VSA] Generating predicate for image: %s", comp.ContainerImage)
603+
pred, err := generator.GeneratePredicate(ctx, report, comp)
604+
if err != nil {
605+
return "", fmt.Errorf("failed to generate predicate for image %s: %w", comp.ContainerImage, err)
606+
}
607+
log.Debugf("[VSA] Predicate generated for image: %s", comp.ContainerImage)
608+
609+
log.Debugf("[VSA] Writing VSA for image: %s", comp.ContainerImage)
610+
writtenPath, err := writer.WriteVSA(pred)
611+
if err != nil {
612+
return "", fmt.Errorf("failed to write VSA for image %s: %w", comp.ContainerImage, err)
613+
}
614+
log.Debugf("[VSA] VSA written to %s", writtenPath)
615+
616+
return writtenPath, nil
617+
}
618+
619+
// processVSA handles the complete VSA generation, signing and upload process for a component
620+
func processVSA(ctx context.Context, report applicationsnapshot.Report, comp applicationsnapshot.Component) error {
621+
generator := vsa.NewGenerator()
622+
writer := vsa.NewWriter()
623+
624+
vsaPath, err := generateAndWriteVSA(ctx, report, comp, generator, writer)
625+
log.Infof("[VSA] VSA written to %s", vsaPath)
626+
if err != nil {
627+
return err
628+
}
629+
return nil
630+
}

internal/validate/vsa/vsa.go

Lines changed: 82 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"encoding/json"
2222
"fmt"
23+
"os"
2324
"path/filepath"
2425
"time"
2526

@@ -31,98 +32,109 @@ import (
3132
log "github.com/sirupsen/logrus"
3233
"github.com/spf13/afero"
3334

34-
"github.com/enterprise-contract/ec-cli/internal/applicationsnapshot"
35-
"github.com/enterprise-contract/ec-cli/internal/evaluator"
35+
"github.com/conforma/cli/internal/applicationsnapshot"
36+
"github.com/conforma/cli/internal/evaluator"
3637
)
3738

38-
// FS is the filesystem used for all file operations in this package. It defaults to the OS filesystem but can be replaced for testing.
39-
var FS afero.Fs = afero.NewOsFs()
40-
41-
// Predicate defines the structure of the per-image VSA predicate.
39+
// Predicate represents a Verification Summary Attestation (VSA) predicate.
4240
type Predicate struct {
4341
ImageRef string `json:"imageRef"`
44-
ValidationResult string `json:"validationResult"` // "passed" or "failed"
42+
ValidationResult string `json:"validationResult"`
4543
Timestamp string `json:"timestamp"`
4644
Verifier string `json:"verifier"`
4745
PolicySource string `json:"policySource"`
4846
Component map[string]interface{} `json:"component"`
4947
RuleResults []evaluator.Result `json:"ruleResults"`
5048
}
5149

52-
// Options for VSA generation
53-
// Extend as needed for more context
54-
// (e.g., output dir, signing key, etc.)
55-
type Options struct {
56-
OutputDir string
57-
SigningKeyPath string
50+
// Generator handles VSA predicate generation
51+
type Generator struct{}
52+
53+
// NewGenerator creates a new VSA predicate generator
54+
func NewGenerator() *Generator {
55+
return &Generator{}
5856
}
5957

6058
// GeneratePredicate creates a Predicate for a validated image/component.
61-
func GeneratePredicate(ctx context.Context, report applicationsnapshot.Report, component applicationsnapshot.Component, opts Options) (*Predicate, error) {
62-
log.Infof("Generating VSA predicate for image: %s", component.ContainerImage)
59+
func (g *Generator) GeneratePredicate(ctx context.Context, report applicationsnapshot.Report, comp applicationsnapshot.Component) (*Predicate, error) {
60+
log.Infof("Generating VSA predicate for image: %s", comp.ContainerImage)
6361

6462
// Compose the component info as a map
6563
componentInfo := map[string]interface{}{
66-
"name": component.Name,
67-
"containerImage": component.ContainerImage,
68-
"source": component.Source,
64+
"name": comp.Name,
65+
"containerImage": comp.ContainerImage,
66+
"source": comp.Source,
6967
}
7068

7169
// Compose rule results: combine violations, warnings, and successes
72-
ruleResults := make([]evaluator.Result, 0, len(component.Violations)+len(component.Warnings)+len(component.Successes))
73-
ruleResults = append(ruleResults, component.Violations...)
74-
ruleResults = append(ruleResults, component.Warnings...)
75-
ruleResults = append(ruleResults, component.Successes...)
70+
ruleResults := make([]evaluator.Result, 0, len(comp.Violations)+len(comp.Warnings)+len(comp.Successes))
71+
ruleResults = append(ruleResults, comp.Violations...)
72+
ruleResults = append(ruleResults, comp.Warnings...)
73+
ruleResults = append(ruleResults, comp.Successes...)
7674

7775
validationResult := "failed"
78-
if component.Success {
76+
if comp.Success {
7977
validationResult = "passed"
8078
}
8179

8280
policySource := ""
83-
if report.Policy.PublicKey != "" {
81+
if report.Policy.Name != "" {
8482
policySource = report.Policy.Name
8583
}
8684

8785
return &Predicate{
88-
ImageRef: component.ContainerImage,
86+
ImageRef: comp.ContainerImage,
8987
ValidationResult: validationResult,
9088
Timestamp: time.Now().UTC().Format(time.RFC3339),
91-
Verifier: "Conforma",
89+
Verifier: "ec-cli",
9290
PolicySource: policySource,
9391
Component: componentInfo,
9492
RuleResults: ruleResults,
9593
}, nil
9694
}
9795

98-
// WriteVSA writes the Predicate as a JSON file to the given path.
99-
func WriteVSA(predicate *Predicate, path string) error {
100-
log.Infof("Writing VSA to %s", path)
96+
// Writer handles VSA file writing
97+
type Writer struct {
98+
FS afero.Fs // defaults to the package-level FS or afero.NewOsFs()
99+
TempDirPrefix string // defaults to "vsa-"
100+
FilePerm os.FileMode // defaults to 0600
101+
}
102+
103+
// NewWriter creates a new VSA file writer
104+
func NewWriter() *Writer {
105+
return &Writer{
106+
FS: afero.NewOsFs(),
107+
TempDirPrefix: "vsa-",
108+
FilePerm: 0o600,
109+
}
110+
}
111+
112+
// WriteVSA writes the Predicate as a JSON file to a temp directory and returns the path.
113+
func (w *Writer) WriteVSA(predicate *Predicate) (string, error) {
114+
log.Infof("Writing VSA for image: %s", predicate.ImageRef)
115+
116+
// Serialize with indent
101117
data, err := json.MarshalIndent(predicate, "", " ")
102118
if err != nil {
103-
return fmt.Errorf("failed to marshal VSA predicate: %w", err)
104-
}
105-
if err := FS.MkdirAll(filepath.Dir(path), 0755); err != nil {
106-
return fmt.Errorf("failed to create VSA output directory: %w", err)
119+
return "", fmt.Errorf("failed to marshal VSA predicate: %w", err)
107120
}
108-
if err := afero.WriteFile(FS, path, data, 0600); err != nil {
109-
log.Errorf("Failed to write VSA file: %v", err)
110-
return fmt.Errorf("failed to write VSA file: %w", err)
121+
122+
// Create temp directory using the injected FS and prefix
123+
tempDir, err := afero.TempDir(w.FS, "", w.TempDirPrefix)
124+
if err != nil {
125+
return "", fmt.Errorf("failed to create temp directory: %w", err)
111126
}
112-
return nil
113-
}
114127

115-
// For testability, allow dependency injection of key loader and sign function
116-
// These types match the cosign APIs
128+
fullPath := filepath.Join(tempDir, "vsa.json")
117129

118-
type PrivateKeyLoader func(key []byte, pass []byte) (signature.SignerVerifier, error)
119-
type AttestationSigner func(ctx context.Context, signer signature.SignerVerifier, ref name.Reference, att oci.Signature, opts *cosign.CheckOpts) (name.Digest, error)
130+
log.Infof("Writing VSA file to %s", fullPath)
131+
// Write file with injected FS and file-permissions
132+
if err := afero.WriteFile(w.FS, fullPath, data, w.FilePerm); err != nil {
133+
log.Errorf("Failed to write VSA file to %s: %v", fullPath, err)
134+
return "", fmt.Errorf("failed to write VSA file: %w", err)
135+
}
120136

121-
// SignVSAOptions allows injection for testing
122-
// If nil, defaults to production cosign implementations
123-
type SignVSAOptions struct {
124-
KeyLoader PrivateKeyLoader
125-
SignFunc AttestationSigner
137+
return fullPath, nil
126138
}
127139

128140
// AttestationUploader is a function that uploads an attestation and returns a result string or error
@@ -147,15 +159,35 @@ func NoopUploader(ctx context.Context, att oci.Signature, location string) (stri
147159
return "", nil
148160
}
149161

150-
// SignVSA signs the VSA file and returns an oci.Signature (does not upload)
151-
func SignVSA(ctx context.Context, vsaPath, keyPath, imageRef string, opts ...SignVSAOptions) (oci.Signature, error) {
162+
type PrivateKeyLoader func(key []byte, pass []byte) (signature.SignerVerifier, error)
163+
type AttestationSigner func(ctx context.Context, signer signature.SignerVerifier, ref name.Reference, att oci.Signature, opts *cosign.CheckOpts) (name.Digest, error)
164+
165+
type Signer struct {
166+
FS afero.Fs // for reading the VSA file
167+
KeyLoader PrivateKeyLoader // injected loader
168+
SignFunc AttestationSigner // injected cosign API
169+
}
170+
171+
func NewSigner(fs afero.Fs, loader PrivateKeyLoader, signer AttestationSigner) *Signer {
172+
return &Signer{
173+
FS: fs,
174+
KeyLoader: loader,
175+
SignFunc: signer,
176+
}
177+
}
178+
179+
// Sign reads the file, loads the key, and returns the signature.
180+
func (s *Signer) Sign(ctx context.Context, vsaPath, keyPath, imageRef string) (oci.Signature, error) {
152181
log.Infof("Signing VSA for image: %s", imageRef)
153-
vsaData, err := afero.ReadFile(FS, vsaPath)
182+
vsaData, err := afero.ReadFile(s.FS, vsaPath)
154183
if err != nil {
155184
log.Errorf("Failed to read VSA file: %v", err)
156185
return nil, fmt.Errorf("failed to read VSA file: %w", err)
157186
}
158187
// TODO: Actually sign the attestation using cosign APIs. For now, just create the attestation object.
188+
// Example:
189+
// signer, err := s.KeyLoader( /* load key bytes from keyPath */ )
190+
// attestationSigned, err := s.SignFunc(ctx, signer, ref, att, nil)
159191
att, err := static.NewAttestation(vsaData)
160192
if err != nil {
161193
log.Errorf("Failed to create attestation: %v", err)

0 commit comments

Comments
 (0)