Skip to content

Commit aece3e5

Browse files
authored
Merge pull request #3080 from arewm/feature/vsa-format-flag
feat: add --attestation-format flag to support multiple VSA output formats
2 parents 0ecfe30 + f29560c commit aece3e5

File tree

12 files changed

+1010
-158
lines changed

12 files changed

+1010
-158
lines changed

cmd/validate/image.go

Lines changed: 205 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ import (
2121
"encoding/json"
2222
"errors"
2323
"fmt"
24+
"os"
25+
"path/filepath"
2426
"runtime/trace"
27+
"slices"
2528
"strings"
2629
"time"
2730

@@ -50,40 +53,7 @@ type imageValidationFunc func(context.Context, app.SnapshotComponent, *app.Snaps
5053
var newOPAEvaluator = evaluator.NewOPAEvaluator
5154

5255
func validateImageCmd(validate imageValidationFunc) *cobra.Command {
53-
data := struct {
54-
certificateIdentity string
55-
certificateIdentityRegExp string
56-
certificateOIDCIssuer string
57-
certificateOIDCIssuerRegExp string
58-
effectiveTime string
59-
extraRuleData []string
60-
filePath string // Deprecated: images replaced this
61-
filterType string
62-
imageRef string
63-
info bool
64-
input string // Deprecated: images replaced this
65-
ignoreRekor bool
66-
output []string
67-
outputFile string
68-
policy policy.Policy
69-
policyConfiguration string
70-
policySource string
71-
publicKey string
72-
rekorURL string
73-
snapshot string
74-
spec *app.SnapshotSpec
75-
// Only used to pass the expansion info to the report. Not a cli flag.
76-
expansion *applicationsnapshot.ExpansionInfo
77-
strict bool
78-
images string
79-
noColor bool
80-
forceColor bool
81-
workers int
82-
vsaEnabled bool
83-
vsaSigningKey string
84-
vsaUpload []string
85-
vsaExpiration time.Duration
86-
}{
56+
data := &imageData{
8757
strict: true,
8858
workers: 5,
8959
filterType: "include-exclude", // Default to include-exclude filter
@@ -309,6 +279,19 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
309279
data.policy = p
310280
}
311281

282+
// Validate VSA configuration
283+
if data.vsaEnabled {
284+
if !slices.Contains([]string{"dsse", "predicate"}, data.attestationFormat) {
285+
allErrors = errors.Join(allErrors, fmt.Errorf("invalid --attestation-format: %s (valid: dsse, predicate)", data.attestationFormat))
286+
}
287+
if data.attestationFormat == "dsse" && data.vsaSigningKey == "" {
288+
allErrors = errors.Join(allErrors, fmt.Errorf("--vsa-signing-key required for --attestation-format=dsse"))
289+
}
290+
if data.attestationFormat == "predicate" && data.vsaSigningKey != "" {
291+
log.Warn("--vsa-signing-key is ignored for --attestation-format=predicate")
292+
}
293+
}
294+
312295
return
313296
},
314297

@@ -457,77 +440,21 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
457440
}
458441

459442
if data.vsaEnabled {
460-
// Use the signer function that supports both file and k8s:// URLs
461-
signer, err := vsa.NewSigner(cmd.Context(), data.vsaSigningKey, utils.FS(cmd.Context()))
443+
// Validate and get output directory
444+
outputDir, err := validateAttestationOutputPath(data.attestationOutputDir)
462445
if err != nil {
463-
log.Error(err)
464-
return err
465-
}
466-
467-
// Create VSA service
468-
vsaService := vsa.NewServiceWithFS(signer, utils.FS(cmd.Context()), data.policySource, data.policy)
469-
470-
// Define helper functions for getting git URL and digest
471-
getGitURL := func(comp applicationsnapshot.Component) string {
472-
if comp.Source.GitSource != nil {
473-
return comp.Source.GitSource.URL
474-
}
475-
return ""
446+
return fmt.Errorf("invalid attestation output directory: %w", err)
476447
}
477448

478-
getDigest := func(comp applicationsnapshot.Component) (string, error) {
479-
imageRef, err := name.ParseReference(comp.ContainerImage)
480-
if err != nil {
481-
return "", fmt.Errorf("failed to parse image reference %s: %v", comp.ContainerImage, err)
449+
// Dispatch to appropriate method based on format
450+
switch data.attestationFormat {
451+
case "dsse":
452+
if err := data.generateVSAsDSSE(cmd, report, outputDir); err != nil {
453+
return err
482454
}
483-
484-
digest, err := oci.NewClient(cmd.Context()).ResolveDigest(imageRef)
485-
if err != nil {
486-
return "", fmt.Errorf("failed to resolve digest for image %s: %v", comp.ContainerImage, err)
487-
}
488-
489-
return digest, nil
490-
}
491-
492-
// Process all VSAs using the service
493-
vsaResult, err := vsaService.ProcessAllVSAs(cmd.Context(), report, getGitURL, getDigest)
494-
if err != nil {
495-
log.Errorf("Failed to process VSAs: %v", err)
496-
// Don't return error here, continue with the rest of the command
497-
} else {
498-
// Upload VSAs to configured storage backends
499-
if len(data.vsaUpload) > 0 {
500-
log.Infof("[VSA] Starting upload to %d storage backend(s)", len(data.vsaUpload))
501-
502-
// Upload component VSA envelopes
503-
for imageRef, envelopePath := range vsaResult.ComponentEnvelopes {
504-
uploadErr := vsa.UploadVSAEnvelope(cmd.Context(), envelopePath, data.vsaUpload, signer)
505-
if uploadErr != nil {
506-
log.Errorf("[VSA] Upload failed for component %s: %v", imageRef, uploadErr)
507-
} else {
508-
log.Infof("[VSA] Uploaded Component VSA")
509-
}
510-
}
511-
512-
// Upload snapshot VSA envelope if it exists
513-
if vsaResult.SnapshotEnvelope != "" {
514-
uploadErr := vsa.UploadVSAEnvelope(cmd.Context(), vsaResult.SnapshotEnvelope, data.vsaUpload, signer)
515-
if uploadErr != nil {
516-
log.Errorf("[VSA] Upload failed for snapshot: %v", uploadErr)
517-
} else {
518-
log.Infof("[VSA] Uploaded Snapshot VSA")
519-
}
520-
}
521-
} else {
522-
// No upload backends configured - inform user about next steps
523-
totalFiles := len(vsaResult.ComponentEnvelopes)
524-
if vsaResult.SnapshotEnvelope != "" {
525-
totalFiles++
526-
}
527-
528-
if totalFiles > 0 {
529-
log.Errorf("[VSA] VSA files generated but not uploaded (no --vsa-upload backends specified)")
530-
}
455+
case "predicate":
456+
if err := data.generateVSAsPredicates(cmd, report, outputDir); err != nil {
457+
return err
531458
}
532459
}
533460
}
@@ -630,9 +557,11 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
630557
- "ec-policy": Uses Enterprise Contract policy filtering with pipeline intention support`))
631558

632559
cmd.Flags().BoolVar(&data.vsaEnabled, "vsa", false, "Generate a Verification Summary Attestation (VSA) for each validated image.")
560+
cmd.Flags().StringVar(&data.attestationFormat, "attestation-format", "dsse", "Attestation output format: dsse (signed envelope), predicate (raw JSON)")
633561
cmd.Flags().StringVar(&data.vsaSigningKey, "vsa-signing-key", "", "Path to the private key for signing the VSA. Supports file paths and Kubernetes secret references (k8s://namespace/secret-name/key-field).")
634562
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")
635563
cmd.Flags().DurationVar(&data.vsaExpiration, "vsa-expiration", data.vsaExpiration, "Expiration threshold for existing VSAs. If a valid VSA exists and is newer than this threshold, validation will be skipped. (default 168h)")
564+
cmd.Flags().StringVar(&data.attestationOutputDir, "attestation-output-dir", "", "Directory for attestation output files. Defaults to a temp directory under /tmp. Must be under /tmp or the current working directory.")
636565

637566
if len(data.input) > 0 || len(data.filePath) > 0 || len(data.images) > 0 {
638567
if err := cmd.MarkFlagRequired("image"); err != nil {
@@ -643,4 +572,179 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
643572
return cmd
644573
}
645574

575+
// validateAttestationOutputPath validates and returns the absolute path for attestation output.
576+
// If path is empty, defaults to a temp directory under /tmp with "vsa-" prefix.
577+
// If path is provided, validates it's under /tmp or current working directory.
578+
func validateAttestationOutputPath(path string) (string, error) {
579+
// Default to temp directory if not provided
580+
if path == "" {
581+
return "vsa-", nil
582+
}
583+
584+
// Clean and get absolute path
585+
cleanPath := filepath.Clean(path)
586+
absPath, err := filepath.Abs(cleanPath)
587+
if err != nil {
588+
return "", fmt.Errorf("failed to get absolute path for %s: %w", path, err)
589+
}
590+
591+
// Get current working directory
592+
cwd, err := os.Getwd()
593+
if err != nil {
594+
return "", fmt.Errorf("failed to get current working directory: %w", err)
595+
}
596+
597+
// Check if path is under /tmp
598+
tmpDir := filepath.Clean("/tmp")
599+
if strings.HasPrefix(absPath, tmpDir+string(filepath.Separator)) || absPath == tmpDir {
600+
return absPath, nil
601+
}
602+
603+
// Check if path is under current working directory
604+
if strings.HasPrefix(absPath, cwd+string(filepath.Separator)) || absPath == cwd {
605+
return absPath, nil
606+
}
607+
608+
return "", fmt.Errorf("attestation output directory must be under /tmp or current working directory, got: %s", absPath)
609+
}
610+
611+
// imageData is the struct that holds all image validation command data
612+
type imageData struct {
613+
certificateIdentity string
614+
certificateIdentityRegExp string
615+
certificateOIDCIssuer string
616+
certificateOIDCIssuerRegExp string
617+
effectiveTime string
618+
extraRuleData []string
619+
filePath string
620+
filterType string
621+
imageRef string
622+
info bool
623+
input string
624+
ignoreRekor bool
625+
output []string
626+
outputFile string
627+
policy policy.Policy
628+
policyConfiguration string
629+
policySource string
630+
publicKey string
631+
rekorURL string
632+
snapshot string
633+
spec *app.SnapshotSpec
634+
expansion *applicationsnapshot.ExpansionInfo
635+
strict bool
636+
images string
637+
noColor bool
638+
forceColor bool
639+
workers int
640+
vsaEnabled bool
641+
attestationFormat string
642+
vsaSigningKey string
643+
vsaUpload []string
644+
vsaExpiration time.Duration
645+
attestationOutputDir string
646+
}
647+
648+
// generateVSAsDSSE generates DSSE VSA envelopes for all validated components
649+
func (data *imageData) generateVSAsDSSE(cmd *cobra.Command, report applicationsnapshot.Report, outputDir string) error {
650+
// Use service for DSSE envelopes
651+
signer, err := vsa.NewSigner(cmd.Context(), data.vsaSigningKey, utils.FS(cmd.Context()))
652+
if err != nil {
653+
log.Error(err)
654+
return err
655+
}
656+
657+
// Create VSA service with output directory
658+
vsaService := vsa.NewServiceWithFS(signer, utils.FS(cmd.Context()), data.policySource, data.policy, outputDir)
659+
660+
// Define helper functions for getting git URL and digest
661+
getGitURL := func(comp applicationsnapshot.Component) string {
662+
if comp.Source.GitSource != nil {
663+
return comp.Source.GitSource.URL
664+
}
665+
return ""
666+
}
667+
668+
getDigest := func(comp applicationsnapshot.Component) (string, error) {
669+
imageRef, err := name.ParseReference(comp.ContainerImage)
670+
if err != nil {
671+
return "", fmt.Errorf("failed to parse image reference %s: %v", comp.ContainerImage, err)
672+
}
673+
674+
digest, err := oci.NewClient(cmd.Context()).ResolveDigest(imageRef)
675+
if err != nil {
676+
return "", fmt.Errorf("failed to resolve digest for image %s: %v", comp.ContainerImage, err)
677+
}
678+
679+
return digest, nil
680+
}
681+
682+
// Process all VSAs using the service
683+
vsaResult, err := vsaService.ProcessAllVSAs(cmd.Context(), report, getGitURL, getDigest)
684+
if err != nil {
685+
log.Errorf("Failed to process VSAs: %v", err)
686+
// Don't return error here, continue with the rest of the command
687+
} else {
688+
// Upload VSAs to configured storage backends
689+
if len(data.vsaUpload) > 0 {
690+
log.Infof("[VSA] Starting upload to %d storage backend(s)", len(data.vsaUpload))
691+
692+
// Upload component VSA envelopes
693+
for imageRef, envelopePath := range vsaResult.ComponentEnvelopes {
694+
uploadErr := vsa.UploadVSAEnvelope(cmd.Context(), envelopePath, data.vsaUpload, signer)
695+
if uploadErr != nil {
696+
log.Errorf("[VSA] Upload failed for component %s: %v", imageRef, uploadErr)
697+
} else {
698+
log.Infof("[VSA] Uploaded Component VSA")
699+
}
700+
}
701+
702+
// Upload snapshot VSA envelope if it exists
703+
if vsaResult.SnapshotEnvelope != "" {
704+
uploadErr := vsa.UploadVSAEnvelope(cmd.Context(), vsaResult.SnapshotEnvelope, data.vsaUpload, signer)
705+
if uploadErr != nil {
706+
log.Errorf("[VSA] Upload failed for snapshot: %v", uploadErr)
707+
} else {
708+
log.Infof("[VSA] Uploaded Snapshot VSA")
709+
}
710+
}
711+
} else {
712+
// No upload backends configured - inform user about next steps
713+
totalFiles := len(vsaResult.ComponentEnvelopes)
714+
if vsaResult.SnapshotEnvelope != "" {
715+
totalFiles++
716+
}
717+
718+
if totalFiles > 0 {
719+
log.Errorf("[VSA] VSA files generated but not uploaded (no --vsa-upload backends specified)")
720+
}
721+
}
722+
}
723+
724+
return nil
725+
}
726+
727+
// generateVSAsPredicates generates raw VSA predicates for all validated components
728+
func (data *imageData) generateVSAsPredicates(cmd *cobra.Command, report applicationsnapshot.Report, outputDir string) error {
729+
for _, comp := range report.Components {
730+
generator := vsa.NewGenerator(report, comp, data.policySource, data.policy)
731+
732+
writer := &vsa.Writer{
733+
FS: utils.FS(cmd.Context()),
734+
TempDirPrefix: outputDir,
735+
FilePerm: 0o600,
736+
}
737+
738+
predicatePath, err := vsa.GenerateAndWritePredicate(cmd.Context(), generator, writer)
739+
if err != nil {
740+
log.Errorf("Failed to generate predicate for %s: %v", comp.ContainerImage, err)
741+
continue
742+
}
743+
744+
log.Infof("[VSA] Generated predicate for %s at %s", comp.ContainerImage, predicatePath)
745+
}
746+
747+
return nil
748+
}
749+
646750
// find if the slice contains "value" output

0 commit comments

Comments
 (0)