Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 44 additions & 23 deletions cmd/validate/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,17 @@
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

Check failure on line 78 in cmd/validate/image.go

View workflow job for this annotation

GitHub Actions / Lint

File is not properly formatted (gci)
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,
Expand Down Expand Up @@ -311,6 +312,11 @@
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
},

Expand Down Expand Up @@ -497,15 +503,21 @@
}

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 {
Expand Down Expand Up @@ -539,23 +551,31 @@
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 {
Expand Down Expand Up @@ -673,6 +693,7 @@
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 {
Expand Down
1 change: 1 addition & 0 deletions docs/modules/ROOT/pages/ec_validate_image.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 43 additions & 3 deletions internal/validate/vsa/file_retriever.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package vsa

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"path/filepath"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
48 changes: 46 additions & 2 deletions internal/validate/vsa/file_retriever_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package vsa

import (
"context"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
Expand Down Expand Up @@ -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)",
},
}

Expand Down Expand Up @@ -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"])
}
29 changes: 29 additions & 0 deletions internal/validate/vsa/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
76 changes: 76 additions & 0 deletions internal/validate/vsa/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading