Skip to content
Merged
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
79 changes: 29 additions & 50 deletions cmd/validate/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,11 +458,38 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
}

if data.vsaEnabled {
generator := vsa.NewGenerator(report)
writer := vsa.NewWriter()
for _, comp := range components {
if err := processVSA(cmd.Context(), report, comp); err != nil {
log.Errorf("[VSA] Error processing VSA for image %s: %v", comp.ContainerImage, err)
writtenPath, err := vsa.GenerateAndWriteVSA(cmd.Context(), generator, writer, comp)
if err != nil {
log.Error(err)
continue
}

signer, err := vsa.NewSigner(data.vsaSigningKey, utils.FS(cmd.Context()))
if err != nil {
log.Error(err)
continue
}

// Get the git URL safely, defaulting to empty string if GitSource is nil
var gitURL string
if comp.Source.GitSource != nil {
gitURL = comp.Source.GitSource.URL
}

attestor, err := vsa.NewAttestor(writtenPath, gitURL, comp.ContainerImage, signer)
if err != nil {
log.Error(err)
continue
}
envelope, err := vsa.AttestVSA(cmd.Context(), attestor, comp)
if err != nil {
log.Error(err)
continue
}
log.Infof("[VSA] VSA attested and envelope written to %s", envelope)
}
}
if data.strict && !report.Success {
Expand Down Expand Up @@ -580,51 +607,3 @@ func containsOutput(data []string, value string) bool {
}
return false
}

// PredicateGenerator defines the interface for generating VSA predicates
type PredicateGenerator interface {
GeneratePredicate(ctx context.Context, report applicationsnapshot.Report, comp applicationsnapshot.Component) (*vsa.Predicate, error)
}

// VSAWriter defines the interface for writing VSA files
type VSAWriter interface {
WriteVSA(predicate *vsa.Predicate) (string, error)
}

// generateAndWriteVSA generates a VSA predicate and writes it to a file
func generateAndWriteVSA(
ctx context.Context,
report applicationsnapshot.Report,
comp applicationsnapshot.Component,
generator PredicateGenerator,
writer VSAWriter,
) (string, error) {
log.Debugf("[VSA] Generating predicate for image: %s", comp.ContainerImage)
pred, err := generator.GeneratePredicate(ctx, report, comp)
if err != nil {
return "", fmt.Errorf("failed to generate predicate for image %s: %w", comp.ContainerImage, err)
}
log.Debugf("[VSA] Predicate generated for image: %s", comp.ContainerImage)

log.Debugf("[VSA] Writing VSA for image: %s", comp.ContainerImage)
writtenPath, err := writer.WriteVSA(pred)
if err != nil {
return "", fmt.Errorf("failed to write VSA for image %s: %w", comp.ContainerImage, err)
}
log.Debugf("[VSA] VSA written to %s", writtenPath)

return writtenPath, nil
}

// processVSA handles the complete VSA generation, signing and upload process for a component
func processVSA(ctx context.Context, report applicationsnapshot.Report, comp applicationsnapshot.Component) error {
generator := vsa.NewGenerator()
writer := vsa.NewWriter()

vsaPath, err := generateAndWriteVSA(ctx, report, comp, generator, writer)
log.Infof("[VSA] VSA written to %s", vsaPath)
if err != nil {
return err
}
return nil
}
128 changes: 128 additions & 0 deletions internal/validate/vsa/attest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright The Conforma Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

// attest.go
package vsa

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/sigstore/cosign/v2/pkg/cosign"
att "github.com/sigstore/cosign/v2/pkg/cosign/attestation"
"github.com/sigstore/cosign/v2/pkg/types"
"github.com/sigstore/sigstore/pkg/signature"
"github.com/sigstore/sigstore/pkg/signature/dsse"
sigopts "github.com/sigstore/sigstore/pkg/signature/options"
"github.com/spf13/afero"
)

var loadPrivateKey = cosign.LoadPrivateKey

type Attestor struct {
PredicatePath string // path to the raw VSA (predicate) JSON
PredicateType string // e.g. "https://enterprisecontract.dev/attestations/vsa/v1" // TODO: make this configurable
ImageDigest string // sha256:abcd… (as returned by `skopeo inspect --format {{.Digest}}`)
Repo string // "quay.io/acme/widget" (hostname/namespace/repo)
Signer *Signer
}

type Signer struct {
KeyPath string
FS afero.Fs
WrapSigner signature.Signer
}

func NewSigner(keyPath string, fs afero.Fs) (*Signer, error) {
keyBytes, err := afero.ReadFile(fs, keyPath)
if err != nil {
return nil, fmt.Errorf("read key %q: %w", keyPath, err)
}

signerVerifier, err := loadPrivateKey(keyBytes, []byte(os.Getenv("COSIGN_PASSWORD")))
if err != nil {
return nil, fmt.Errorf("load private key: %w", err)
}

return &Signer{
KeyPath: keyPath,
FS: fs,
WrapSigner: dsse.WrapSigner(signerVerifier, types.IntotoPayloadType),
}, nil
}

// Add a constructor with sensible defaults
func NewAttestor(predicatePath, repo, imageDigest string, signer *Signer) (*Attestor, error) {
return &Attestor{
PredicatePath: predicatePath,
PredicateType: "https://conforma.dev/verification_summary/v1",
ImageDigest: imageDigest,
Repo: repo,
Signer: signer,
}, nil
}

// AttestPredicate builds an in‑toto Statement around the predicate and
// returns the fully‑signed **DSSE envelope** (identical to cosign's
// --no-upload output). Nothing is pushed to a registry or the TLog.
func (a Attestor) AttestPredicate(ctx context.Context) ([]byte, error) {
//-------------------------------------------------------------------- 2. read predicate
predFile, err := a.Signer.FS.Open(a.PredicatePath)
if err != nil {
return nil, fmt.Errorf("open predicate: %w", err)
}
defer predFile.Close()

//-------------------------------------------------------------------- 3. make the in‑toto statement
stmt, err := att.GenerateStatement(att.GenerateOpts{
Predicate: predFile,
Type: a.PredicateType,
Digest: strings.TrimPrefix(a.ImageDigest, "sha256:"),
Repo: a.Repo,
Time: time.Now, // keeps tests deterministic
})
if err != nil {
return nil, fmt.Errorf("wrap predicate: %w", err)
}
payload, _ := json.Marshal(stmt) // canonicalised by dsse later

//-------------------------------------------------------------------- 4. sign -> DSSE envelope
env, err := a.Signer.WrapSigner.SignMessage(bytes.NewReader(payload), sigopts.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("sign statement: %w", err)
}
return env, nil // byte‑slice containing the JSON DSSE envelope
}

// WriteEnvelope is an optional convenience that mirrors cosign's
// --output‑signature flag; it emits <predicate>.intoto.jsonl next to the file.
func (a Attestor) WriteEnvelope(data []byte) (string, error) {
out := a.PredicatePath + ".intoto.jsonl"
if err := afero.WriteFile(a.Signer.FS, out, data, 0o644); err != nil {
return "", err
}
abs, err := filepath.Abs(out)
if err != nil {
return "", err
}
return abs, nil
}
Loading
Loading