diff --git a/cmd/validate/image.go b/cmd/validate/image.go index 35a7b8d29..e91a465f4 100644 --- a/cmd/validate/image.go +++ b/cmd/validate/image.go @@ -75,16 +75,17 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { snapshot string spec *app.SnapshotSpec // Only used to pass the expansion info to the report. Not a cli flag. - expansion *applicationsnapshot.ExpansionInfo - strict bool - images string - noColor bool - forceColor bool - workers int - vsaEnabled bool - vsaSigningKey string - vsaUpload []string - vsaExpiration time.Duration + expansion *applicationsnapshot.ExpansionInfo + strict bool + images string + noColor bool + forceColor bool + workers int + vsaEnabled bool + vsaSigningKey string + vsaUpload []string + vsaExpiration time.Duration + disableVSASigning bool }{ strict: true, workers: 5, @@ -311,6 +312,11 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { data.policy = p } + // Validate VSA flags + if data.vsaEnabled && !data.disableVSASigning && data.vsaSigningKey == "" { + allErrors = errors.Join(allErrors, fmt.Errorf("--vsa-signing-key is required when --vsa is enabled and --disable-vsa-signing is not set")) + } + return }, @@ -497,15 +503,21 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { } if data.vsaEnabled { - // Use the signer function that supports both file and k8s:// URLs - signer, err := vsa.NewSigner(cmd.Context(), data.vsaSigningKey, utils.FS(cmd.Context())) - if err != nil { - log.Error(err) - return err + var signer *vsa.Signer + var err error + + // Only create signer if VSA signing is enabled + if !data.disableVSASigning { + // Use the signer function that supports both file and k8s:// URLs + signer, err = vsa.NewSigner(cmd.Context(), data.vsaSigningKey, utils.FS(cmd.Context())) + if err != nil { + log.Error(err) + return err + } } - // Create VSA service - vsaService := vsa.NewServiceWithFS(signer, utils.FS(cmd.Context()), data.policySource, data.policy) + // Create VSA service with explicit unsigned mode setting + vsaService := vsa.NewServiceWithOptions(signer, utils.FS(cmd.Context()), data.policySource, data.policy, data.disableVSASigning) // Define helper functions for getting git URL and digest getGitURL := func(comp applicationsnapshot.Component) string { @@ -539,23 +551,31 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { if len(data.vsaUpload) > 0 { log.Infof("[VSA] Starting upload to %d storage backend(s)", len(data.vsaUpload)) - // Upload component VSA envelopes - for imageRef, envelopePath := range vsaResult.ComponentEnvelopes { - uploadErr := vsa.UploadVSAEnvelope(cmd.Context(), envelopePath, data.vsaUpload, signer) + // Upload component VSA envelopes (or raw predicates if signing is disabled) + for imageRef, filePath := range vsaResult.ComponentEnvelopes { + uploadErr := vsa.UploadVSAEnvelope(cmd.Context(), filePath, data.vsaUpload, signer) if uploadErr != nil { log.Errorf("[VSA] Upload failed for component %s: %v", imageRef, uploadErr) } else { - log.Infof("[VSA] Uploaded Component VSA") + if data.disableVSASigning { + log.Infof("[VSA] Uploaded Component VSA (raw predicate)") + } else { + log.Infof("[VSA] Uploaded Component VSA (signed envelope)") + } } } - // Upload snapshot VSA envelope if it exists + // Upload snapshot VSA envelope (or raw predicate if signing is disabled) if it exists if vsaResult.SnapshotEnvelope != "" { uploadErr := vsa.UploadVSAEnvelope(cmd.Context(), vsaResult.SnapshotEnvelope, data.vsaUpload, signer) if uploadErr != nil { log.Errorf("[VSA] Upload failed for snapshot: %v", uploadErr) } else { - log.Infof("[VSA] Uploaded Snapshot VSA") + if data.disableVSASigning { + log.Infof("[VSA] Uploaded Snapshot VSA (raw predicate)") + } else { + log.Infof("[VSA] Uploaded Snapshot VSA (signed envelope)") + } } } } else { @@ -673,6 +693,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { 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).") 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") 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)") + cmd.Flags().BoolVar(&data.disableVSASigning, "disable-vsa-signing", false, "Upload raw VSA attestation without wrapping it in a signed DSSE envelope.") if len(data.input) > 0 || len(data.filePath) > 0 || len(data.images) > 0 { if err := cmd.MarkFlagRequired("image"); err != nil { diff --git a/docs/modules/ROOT/pages/ec_validate_image.adoc b/docs/modules/ROOT/pages/ec_validate_image.adoc index 05b1bf823..139003a06 100644 --- a/docs/modules/ROOT/pages/ec_validate_image.adoc +++ b/docs/modules/ROOT/pages/ec_validate_image.adoc @@ -115,6 +115,7 @@ Use a regular expression to match certificate attributes. --certificate-oidc-issuer:: URL of the certificate OIDC issuer for keyless verification --certificate-oidc-issuer-regexp:: Regular expresssion for the URL of the certificate OIDC issuer for keyless verification --color:: Enable color when using text output even when the current terminal does not support it (Default: false) +--disable-vsa-signing:: Upload raw VSA attestation without wrapping it in a signed DSSE envelope. (Default: false) --effective-time:: Run policy checks with the provided time. Useful for testing rules with effective dates in the future. The value can be "now" (default) - for current time, "attestation" - for time from the youngest attestation, or diff --git a/internal/validate/vsa/file_retriever.go b/internal/validate/vsa/file_retriever.go index 9e136537e..f3153f03d 100644 --- a/internal/validate/vsa/file_retriever.go +++ b/internal/validate/vsa/file_retriever.go @@ -18,6 +18,7 @@ package vsa import ( "context" + "encoding/base64" "encoding/json" "fmt" "path/filepath" @@ -79,10 +80,10 @@ func (f *FileVSARetriever) RetrieveVSA(ctx context.Context, identifier string) ( return nil, fmt.Errorf("failed to read VSA file: %w", err) } - // Parse the DSSE envelope - envelope, err := f.parseDSSEEnvelope(data) + // Try to parse as DSSE envelope first, then fall back to raw predicate + envelope, err := f.parseVSAContent(data) if err != nil { - return nil, fmt.Errorf("failed to parse DSSE envelope from file: %w", err) + return nil, fmt.Errorf("failed to parse VSA content from file: %w", err) } log.Debugf("Successfully retrieved VSA from file: %s", filePath) @@ -126,6 +127,45 @@ func (f *FileVSARetriever) parseDSSEEnvelope(data []byte) (*ssldsse.Envelope, er return &envelope, nil } +// parseVSAContent attempts to parse VSA content from either DSSE envelope or raw predicate format +func (f *FileVSARetriever) parseVSAContent(data []byte) (*ssldsse.Envelope, error) { + // First, try to parse as a DSSE envelope (signed format) + envelope, err := f.parseDSSEEnvelope(data) + if err == nil { + return envelope, nil + } + + // If DSSE parsing failed, try to parse as raw predicate (unsigned format) + return f.parseRawPredicate(data) +} + +// parseRawPredicate parses a raw VSA predicate and wraps it in a minimal DSSE envelope structure +func (f *FileVSARetriever) parseRawPredicate(data []byte) (*ssldsse.Envelope, error) { + // Verify it's valid JSON and looks like a VSA predicate + var predicate map[string]interface{} + if err := json.Unmarshal(data, &predicate); err != nil { + return nil, fmt.Errorf("content is neither valid DSSE envelope nor valid JSON predicate: %w", err) + } + + // Check if it looks like a VSA predicate (should have expected fields) + if _, hasPolicy := predicate["policy"]; !hasPolicy { + return nil, fmt.Errorf("content does not appear to be a VSA predicate (missing 'policy' field)") + } + if _, hasTimestamp := predicate["timestamp"]; !hasTimestamp { + return nil, fmt.Errorf("content does not appear to be a VSA predicate (missing 'timestamp' field)") + } + + // Create a minimal DSSE envelope with the raw predicate as base64-encoded payload + // This allows the rest of the VSA processing pipeline to work unchanged + envelope := &ssldsse.Envelope{ + PayloadType: "application/vnd.in-toto+json", + Payload: base64.StdEncoding.EncodeToString(data), + Signatures: []ssldsse.Signature{}, // Empty signatures for unsigned content + } + + return envelope, nil +} + // FileVSARetrieverOptions configures filesystem-based VSA retrieval behavior type FileVSARetrieverOptions struct { BasePath string diff --git a/internal/validate/vsa/file_retriever_test.go b/internal/validate/vsa/file_retriever_test.go index 6b597b83b..e9bd4fcd3 100644 --- a/internal/validate/vsa/file_retriever_test.go +++ b/internal/validate/vsa/file_retriever_test.go @@ -18,6 +18,7 @@ package vsa import ( "context" + "encoding/base64" "encoding/json" "os" "path/filepath" @@ -301,13 +302,13 @@ func TestFileVSARetriever_RetrieveVSA_ErrorCases(t *testing.T) { name: "file with invalid JSON", identifier: "invalid.json", expectError: true, - errorMsg: "failed to unmarshal DSSE envelope", + errorMsg: "content is neither valid DSSE envelope nor valid JSON predicate", }, { name: "file with invalid DSSE structure", identifier: "invalid-dsse.json", expectError: true, - errorMsg: "DSSE envelope missing payloadType", + errorMsg: "content does not appear to be a VSA predicate (missing 'policy' field)", }, } @@ -399,3 +400,46 @@ func TestFileVSARetriever_parseDSSEEnvelope_ErrorCases(t *testing.T) { }) } } + +func TestFileVSARetriever_RawPredicateSupport(t *testing.T) { + fs := afero.NewMemMapFs() + retriever := &FileVSARetriever{fs: fs} + + // Create a raw VSA predicate (unsigned format) + rawPredicate := map[string]interface{}{ + "policy": map[string]interface{}{ + "sources": []map[string]interface{}{ + {"uri": "https://github.com/test/policy"}, + }, + }, + "timestamp": "2023-01-01T00:00:00Z", + "components": []map[string]interface{}{}, + } + + rawPredicateJSON, err := json.Marshal(rawPredicate) + require.NoError(t, err) + err = afero.WriteFile(fs, "raw-vsa.json", rawPredicateJSON, 0600) + require.NoError(t, err) + + // Test that raw predicate can be retrieved successfully + result, err := retriever.RetrieveVSA(context.Background(), "raw-vsa.json") + require.NoError(t, err) + require.NotNil(t, result) + + // Should have DSSE envelope structure but with empty signatures + assert.Equal(t, "application/vnd.in-toto+json", result.PayloadType) + assert.NotEmpty(t, result.Payload) + assert.Empty(t, result.Signatures) // No signatures for unsigned VSA + + // The payload should be base64 encoded version of our raw predicate + decodedPayload, err := base64.StdEncoding.DecodeString(result.Payload) + require.NoError(t, err) + + var decodedPredicate map[string]interface{} + err = json.Unmarshal(decodedPayload, &decodedPredicate) + require.NoError(t, err) + + // Should match our original raw predicate + assert.Equal(t, rawPredicate["timestamp"], decodedPredicate["timestamp"]) + assert.NotNil(t, decodedPredicate["policy"]) +} diff --git a/internal/validate/vsa/service.go b/internal/validate/vsa/service.go index 81c65171c..f97fd769e 100644 --- a/internal/validate/vsa/service.go +++ b/internal/validate/vsa/service.go @@ -39,6 +39,7 @@ type Service struct { fs afero.Fs policySource string policy PublicKeyProvider + unsignedMode bool // Whether to create unsigned VSAs (raw predicates) } // NewServiceWithFS creates a new VSA service with the given signer and filesystem @@ -48,6 +49,18 @@ func NewServiceWithFS(signer *Signer, fs afero.Fs, policySource string, policy P fs: fs, policySource: policySource, policy: policy, + unsignedMode: false, // Default to signed mode + } +} + +// NewServiceWithOptions creates a new VSA service with explicit options including unsigned mode +func NewServiceWithOptions(signer *Signer, fs afero.Fs, policySource string, policy PublicKeyProvider, unsignedMode bool) *Service { + return &Service{ + signer: signer, + fs: fs, + policySource: policySource, + policy: policy, + unsignedMode: unsignedMode, } } @@ -66,6 +79,14 @@ func (s *Service) ProcessComponentVSA(ctx context.Context, report applicationsna return "", fmt.Errorf("failed to generate and write component Predicate: %w", err) } + // If unsigned mode is enabled, return the raw predicate path + if s.unsignedMode { + log.WithFields(log.Fields{ + "predicate_path": writtenPath, + }).Info("[VSA] Component Predicate written (signing disabled)") + return writtenPath, nil + } + // Create attestor and attest Predicate // Use the image reference (without digest) as the repo for the subject name imageRef := comp.ContainerImage @@ -106,6 +127,14 @@ func (s *Service) ProcessSnapshotVSA(ctx context.Context, report applicationsnap return "", fmt.Errorf("failed to calculate digest for snapshot Predicate: %w", err) } + // If unsigned mode is enabled, return the raw predicate path + if s.unsignedMode { + log.WithFields(log.Fields{ + "predicate_path": writtenPath, + }).Info("[VSA] Snapshot Predicate written (signing disabled)") + return writtenPath, nil + } + // Create attestor and attest Predicate // Use a meaningful name for the snapshot subject snapshotName := "application-snapshot" diff --git a/internal/validate/vsa/service_test.go b/internal/validate/vsa/service_test.go index eee7dace2..7e2d5e541 100644 --- a/internal/validate/vsa/service_test.go +++ b/internal/validate/vsa/service_test.go @@ -384,3 +384,79 @@ func TestService_ProcessAllVSAs_PartialSuccess(t *testing.T) { assert.NotEmpty(t, result.SnapshotEnvelope, "SnapshotEnvelope should be processed even with component failures") assert.Contains(t, result.SnapshotEnvelope, ".intoto.jsonl", "Snapshot envelope should be a .intoto.jsonl file") } + +func TestService_ProcessComponentVSA_UnsignedMode(t *testing.T) { + ctx := context.Background() + fs := afero.NewMemMapFs() + + // Create test data + report := applicationsnapshot.Report{ + Success: true, + Policy: ecc.EnterpriseContractPolicySpec{}, + } + + comp := applicationsnapshot.Component{ + SnapshotComponent: app.SnapshotComponent{ + Name: "test-component", + ContainerImage: "quay.io/test/image:tag", + }, + Success: true, + Violations: []evaluator.Result{}, + Warnings: []evaluator.Result{}, + Successes: []evaluator.Result{{Message: "test success"}}, + } + + // Create service with unsigned mode enabled + service := NewServiceWithOptions(nil, fs, "https://github.com/test/policy", nil, true) + + // Test unsigned processing + predicatePath, err := service.ProcessComponentVSA(ctx, report, comp, "https://github.com/test/repo", "sha256:testdigest") + + assert.NoError(t, err) + assert.NotEmpty(t, predicatePath) + // In unsigned mode, should return raw predicate path, not envelope + assert.Contains(t, predicatePath, "vsa-test-component.json") + assert.NotContains(t, predicatePath, ".intoto.jsonl") + + // Verify the predicate file exists and contains expected content + exists, err := afero.Exists(fs, predicatePath) + assert.NoError(t, err) + assert.True(t, exists, "Predicate file should exist") +} + +func TestService_ProcessSnapshotVSA_UnsignedMode(t *testing.T) { + ctx := context.Background() + fs := afero.NewMemMapFs() + + // Create test data + report := applicationsnapshot.Report{ + Success: true, + Policy: ecc.EnterpriseContractPolicySpec{}, + Components: []applicationsnapshot.Component{ + { + SnapshotComponent: app.SnapshotComponent{ + Name: "test-component", + ContainerImage: "quay.io/test/image:tag", + }, + Success: true, + }, + }, + } + + // Create service with unsigned mode enabled + service := NewServiceWithOptions(nil, fs, "https://github.com/test/policy", nil, true) + + // Test unsigned processing + predicatePath, err := service.ProcessSnapshotVSA(ctx, report) + + assert.NoError(t, err) + assert.NotEmpty(t, predicatePath) + // In unsigned mode, should return raw predicate path, not envelope + assert.Contains(t, predicatePath, "snapshot-vsa.json") + assert.NotContains(t, predicatePath, ".intoto.jsonl") + + // Verify the predicate file exists + exists, err := afero.Exists(fs, predicatePath) + assert.NoError(t, err) + assert.True(t, exists, "Predicate file should exist") +}