@@ -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
5053var newOPAEvaluator = evaluator .NewOPAEvaluator
5154
5255func 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