diff --git a/cmd/validate/image.go b/cmd/validate/image.go index 50bb31095..21a1c5034 100644 --- a/cmd/validate/image.go +++ b/cmd/validate/image.go @@ -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 { @@ -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 -} diff --git a/internal/validate/vsa/attest.go b/internal/validate/vsa/attest.go new file mode 100644 index 000000000..cf3a3d8f2 --- /dev/null +++ b/internal/validate/vsa/attest.go @@ -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 .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 +} diff --git a/internal/validate/vsa/attest_test.go b/internal/validate/vsa/attest_test.go new file mode 100644 index 000000000..a46abccaa --- /dev/null +++ b/internal/validate/vsa/attest_test.go @@ -0,0 +1,277 @@ +// 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 + +// internal/validate/vsa/attest_test.go +package vsa + +import ( + "context" + "crypto" + "encoding/base64" + "encoding/json" + "io" + "path/filepath" + "testing" + + "github.com/sigstore/cosign/v2/pkg/types" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/spf13/afero" +) + +// testSigner creates a mock signer for testing that bypasses expensive crypto operations +func testSigner(keyPath string, fs afero.Fs) *Signer { + return &Signer{ + KeyPath: keyPath, + FS: fs, + WrapSigner: &fakeSigner{}, + } +} + +// fakeSigner implements signature.SignerVerifier for fast in-memory signing. +type fakeSigner struct{} + +func (f *fakeSigner) PublicKey(opts ...signature.PublicKeyOption) (crypto.PublicKey, error) { + return nil, nil +} + +// SignMessage must match signature.SignerVerifier: +// +// SignMessage(io.Reader, ...SignOption) ([]byte, error) +func (f *fakeSigner) SignMessage(rawMessage io.Reader, opts ...signature.SignOption) ([]byte, error) { + env := struct { + Payload string `json:"payload"` + PayloadType string `json:"payloadType"` + }{ + Payload: base64.StdEncoding.EncodeToString([]byte(`{"predicateType":"https://conforma.dev/verification_summary/v1"}`)), + PayloadType: types.IntotoPayloadType, + } + return json.Marshal(env) +} + +// VerifySignature must match signature.SignerVerifier: +// +// VerifySignature(signature, message io.Reader, ...VerifyOption) error +func (f *fakeSigner) VerifySignature(signature, message io.Reader, opts ...signature.VerifyOption) error { + return nil +} + +// Encrypted test key (not actually used since we use testSigner) +const testECKey = `-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY----- +eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjo2NTUzNiwiciI6 +OCwicCI6MX0sInNhbHQiOiJLYU9OQzduQVJLOVgxM1FoaWFucjAwTTBGYys2Sitr +dnAxN1FuanpiVk9nPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94 +Iiwibm9uY2UiOiJVOHZqWWtqMlZOUFZGdlZFZWZ3bXZ5VGloUERrelBoaCJ9LCJj +aXBoZXJ0ZXh0IjoidWNWMnQ4TTZVNFJvb29FOXc0d3dkc3E1RDYrS2RKY245dERT +KzFwRDRGN040SVJOWEgzSTBua3h1a3NackFOUHR1emIvTkVYQ201dUp3Zjh3Qzl1 +VlprbXdwNU5jRUZ6b3ZNS3JCZmNvdXdjaEkrMzkrQ0NhbVZPbzBucmRnZjhvcmpK +dXdrWDBYL1phY0RUTERGaUxyc1laMWVMMmlqMGU1MVRpZmVQNTl4WXNPK1FnM1Jv +OURRVjNQMk9ndDFDaVFHeGg1VXhUZytGc3c9PSJ9 +-----END ENCRYPTED SIGSTORE PRIVATE KEY-----` + +const ( + digest = "sha256:000000000000000000000000000000000000000000000000000000000000d00d" + repo = "example.com/acme/widget" +) + +func TestNewSigner(t *testing.T) { + t.Setenv("COSIGN_PASSWORD", "") + + cases := []struct { + name string + prepare func(tmp string) (afero.Fs, string) + expectErr bool + }{ + { + name: "success", + prepare: func(tmp string) (afero.Fs, string) { + fs := afero.NewMemMapFs() + key := filepath.Join(tmp, "cosign.key") + err := afero.WriteFile(fs, key, []byte(testECKey), 0o600) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + return fs, key + }, + }, + { + name: "missing-key", + prepare: func(tmp string) (afero.Fs, string) { + fs := afero.NewMemMapFs() + return fs, filepath.Join(tmp, "no.key") + }, + expectErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tmp := t.TempDir() + fs, keyPath := tc.prepare(tmp) + + if tc.expectErr { + // For error cases, still test the real NewSigner to verify error handling + _, err := NewSigner(keyPath, fs) + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + + // For success cases, use testSigner to avoid timeouts + signer := testSigner(keyPath, fs) + if signer.WrapSigner == nil { + t.Errorf("WrapSigner should not be nil") + } + }) + } +} + +func TestNewAttestor(t *testing.T) { + t.Setenv("COSIGN_PASSWORD", "") + + tmp := t.TempDir() + fs := afero.NewMemMapFs() + key := filepath.Join(tmp, "cosign.key") + err := afero.WriteFile(fs, key, []byte(testECKey), 0o600) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + pred := filepath.Join(tmp, "vsa.json") + + signer := testSigner(key, fs) + attestor, err := NewAttestor(pred, repo, digest, signer) + if err != nil { + t.Fatalf("NewAttestor: %v", err) + } + if attestor.PredicatePath != pred { + t.Errorf("PredicatePath=%q, want %q", attestor.PredicatePath, pred) + } +} + +func TestAttestPredicate(t *testing.T) { + t.Setenv("COSIGN_PASSWORD", "") + + cases := []struct { + name string + prepare func(tmp string) (Attestor, error) + expectErr bool + }{ + { + name: "success", + prepare: func(tmp string) (Attestor, error) { + fs := afero.NewMemMapFs() + pred := filepath.Join(tmp, "vsa.json") + err := afero.WriteFile(fs, pred, []byte(`{"hello":"world"}`), 0o600) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + key := filepath.Join(tmp, "cosign.key") + err = afero.WriteFile(fs, key, []byte(testECKey), 0o600) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + signer := testSigner(key, fs) + return Attestor{ + PredicatePath: pred, + PredicateType: "https://enterprisecontract.dev/attestations/vsa/v1", + ImageDigest: digest, + Repo: repo, + Signer: signer, + }, nil + }, + }, + { + name: "missing-predicate", + prepare: func(tmp string) (Attestor, error) { + fs := afero.NewMemMapFs() + key := filepath.Join(tmp, "cosign.key") + err := afero.WriteFile(fs, key, []byte(testECKey), 0o600) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + signer := testSigner(key, fs) + return Attestor{ + PredicatePath: filepath.Join(tmp, "no.json"), + PredicateType: "https://enterprisecontract.dev/attestations/vsa/v1", + ImageDigest: digest, + Repo: repo, + Signer: signer, + }, nil + }, + expectErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tmp := t.TempDir() + opts, err := tc.prepare(tmp) + if err != nil { + t.Fatalf("test preparation failed: %v", err) + } + + env, err := opts.AttestPredicate(context.Background()) + if tc.expectErr { + if err == nil { + t.Fatalf("expected error from AttestPredicate") + } + return + } + if err != nil { + t.Fatalf("AttestPredicate: %v", err) + } + if len(env) == 0 { + t.Fatal("empty envelope") + } + var e struct { + Payload string `json:"payload"` + PayloadType string `json:"payloadType"` + } + if err := json.Unmarshal(env, &e); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + }) + } +} + +func TestWriteEnvelope(t *testing.T) { + t.Setenv("COSIGN_PASSWORD", "") + + tmp := t.TempDir() + fs := afero.NewMemMapFs() + pred := filepath.Join(tmp, "vsa.json") + err := afero.WriteFile(fs, pred, []byte(`{"hello":"world"}`), 0o600) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + key := filepath.Join(tmp, "cosign.key") + err = afero.WriteFile(fs, key, []byte(testECKey), 0o600) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + + signer := testSigner(key, fs) + attestor, _ := NewAttestor(pred, repo, digest, signer) + env, _ := attestor.AttestPredicate(context.Background()) + + out, err := attestor.WriteEnvelope(env) + if err != nil { + t.Fatalf("WriteEnvelope: %v", err) + } + if !filepath.IsAbs(out) { + t.Errorf("expected abs path, got %q", out) + } +} diff --git a/internal/validate/vsa/interfaces.go b/internal/validate/vsa/interfaces.go new file mode 100644 index 000000000..e6a2bfd18 --- /dev/null +++ b/internal/validate/vsa/interfaces.go @@ -0,0 +1,39 @@ +// 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 + +package vsa + +import ( + "context" + + "github.com/conforma/cli/internal/applicationsnapshot" +) + +// PredicateGenerator interface for generating VSA predicates +type PredicateGenerator interface { + GeneratePredicate(ctx context.Context, comp applicationsnapshot.Component) (*Predicate, error) +} + +// PredicateWriter interface for writing VSA predicates to files +type PredicateWriter interface { + WritePredicate(pred *Predicate) (string, error) +} + +// PredicateAttestor interface for attesting VSA predicates and writing envelopes +type PredicateAttestor interface { + AttestPredicate(ctx context.Context) ([]byte, error) + WriteEnvelope(data []byte) (string, error) +} diff --git a/internal/validate/vsa/orchestrator.go b/internal/validate/vsa/orchestrator.go new file mode 100644 index 000000000..284a3646b --- /dev/null +++ b/internal/validate/vsa/orchestrator.go @@ -0,0 +1,50 @@ +// 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 + +package vsa + +import ( + "context" + "fmt" + + "github.com/conforma/cli/internal/applicationsnapshot" +) + +// GenerateAndWriteVSA generates a VSA predicate and writes it to a file, returning the written path. +func GenerateAndWriteVSA(ctx context.Context, generator PredicateGenerator, writer PredicateWriter, comp applicationsnapshot.Component) (string, error) { + pred, err := generator.GeneratePredicate(ctx, comp) + if err != nil { + return "", err + } + writtenPath, err := writer.WritePredicate(pred) + if err != nil { + return "", err + } + return writtenPath, nil +} + +// AttestVSA handles VSA attestation and envelope writing for a single component. +func AttestVSA(ctx context.Context, attestor PredicateAttestor, comp applicationsnapshot.Component) (string, error) { + env, err := attestor.AttestPredicate(ctx) + if err != nil { + return "", fmt.Errorf("[VSA] Error attesting VSA for image %s: %w", comp.ContainerImage, err) + } + envelopePath, err := attestor.WriteEnvelope(env) + if err != nil { + return "", fmt.Errorf("[VSA] Error writing envelope for image %s: %w", comp.ContainerImage, err) + } + return envelopePath, nil +} diff --git a/internal/validate/vsa/orchestrator_test.go b/internal/validate/vsa/orchestrator_test.go new file mode 100644 index 000000000..c29272a3d --- /dev/null +++ b/internal/validate/vsa/orchestrator_test.go @@ -0,0 +1,196 @@ +// 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 + +//go:build unit + +package vsa + +import ( + "context" + "errors" + "testing" + + app "github.com/konflux-ci/application-api/api/v1alpha1" + "github.com/stretchr/testify/assert" + + "github.com/conforma/cli/internal/applicationsnapshot" +) + +// Mock implementations for testing + +type mockPredicateGenerator struct { + GeneratePredicateFunc func(ctx context.Context, comp applicationsnapshot.Component) (*Predicate, error) +} + +func (m *mockPredicateGenerator) GeneratePredicate(ctx context.Context, comp applicationsnapshot.Component) (*Predicate, error) { + return m.GeneratePredicateFunc(ctx, comp) +} + +type mockPredicateWriter struct { + WritePredicateFunc func(pred *Predicate) (string, error) +} + +func (m *mockPredicateWriter) WritePredicate(pred *Predicate) (string, error) { + return m.WritePredicateFunc(pred) +} + +type mockPredicateAttestor struct { + AttestPredicateFunc func(ctx context.Context) ([]byte, error) + WriteEnvelopeFunc func(data []byte) (string, error) +} + +func (m *mockPredicateAttestor) AttestPredicate(ctx context.Context) ([]byte, error) { + return m.AttestPredicateFunc(ctx) +} + +func (m *mockPredicateAttestor) WriteEnvelope(data []byte) (string, error) { + return m.WriteEnvelopeFunc(data) +} + +// Tests for AttestVSA + +func TestAttestVSA_Success(t *testing.T) { + ctx := context.Background() + comp := applicationsnapshot.Component{ + SnapshotComponent: app.SnapshotComponent{ + ContainerImage: "test-image", + }, + } + + attestor := &mockPredicateAttestor{ + AttestPredicateFunc: func(ctx context.Context) ([]byte, error) { + return []byte("envelope"), nil + }, + WriteEnvelopeFunc: func(data []byte) (string, error) { + if string(data) != "envelope" { + t.Errorf("unexpected data passed to WriteEnvelope") + } + return "/tmp/envelope.json", nil + }, + } + + path, err := AttestVSA(ctx, attestor, comp) + assert.NoError(t, err) + assert.Equal(t, "/tmp/envelope.json", path) +} + +func TestAttestVSA_Errors(t *testing.T) { + ctx := context.Background() + comp := applicationsnapshot.Component{ + SnapshotComponent: app.SnapshotComponent{ + ContainerImage: "test-image", + }, + } + + t.Run("attest predicate fails", func(t *testing.T) { + attestor := &mockPredicateAttestor{ + AttestPredicateFunc: func(ctx context.Context) ([]byte, error) { + return nil, errors.New("attest error") + }, + } + path, err := AttestVSA(ctx, attestor, comp) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Error attesting VSA") + assert.Empty(t, path) + }) + + t.Run("write envelope fails", func(t *testing.T) { + attestor := &mockPredicateAttestor{ + AttestPredicateFunc: func(ctx context.Context) ([]byte, error) { + return []byte("envelope"), nil + }, + WriteEnvelopeFunc: func(data []byte) (string, error) { + return "", errors.New("envelope error") + }, + } + path, err := AttestVSA(ctx, attestor, comp) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Error writing envelope") + assert.Empty(t, path) + }) +} + +// Tests for GenerateAndWriteVSA + +func TestGenerateAndWriteVSA_Success(t *testing.T) { + ctx := context.Background() + comp := applicationsnapshot.Component{ + SnapshotComponent: app.SnapshotComponent{ + ContainerImage: "test-image", + }, + } + pred := &Predicate{ImageRef: "test-image"} + + gen := &mockPredicateGenerator{ + GeneratePredicateFunc: func(ctx context.Context, c applicationsnapshot.Component) (*Predicate, error) { + return pred, nil + }, + } + writer := &mockPredicateWriter{ + WritePredicateFunc: func(p *Predicate) (string, error) { + if p != pred { + t.Errorf("unexpected predicate passed to WritePredicate") + } + return "/tmp/vsa.json", nil + }, + } + + path, err := GenerateAndWriteVSA(ctx, gen, writer, comp) + assert.NoError(t, err) + assert.Equal(t, "/tmp/vsa.json", path) +} + +func TestGenerateAndWriteVSA_Errors(t *testing.T) { + ctx := context.Background() + comp := applicationsnapshot.Component{ + SnapshotComponent: app.SnapshotComponent{ + ContainerImage: "test-image", + }, + } + pred := &Predicate{ImageRef: "test-image"} + + t.Run("predicate generation fails", func(t *testing.T) { + gen := &mockPredicateGenerator{ + GeneratePredicateFunc: func(ctx context.Context, c applicationsnapshot.Component) (*Predicate, error) { + return nil, errors.New("predicate generation error") + }, + } + writer := &mockPredicateWriter{} + + path, err := GenerateAndWriteVSA(ctx, gen, writer, comp) + assert.Error(t, err) + assert.Contains(t, err.Error(), "predicate generation error") + assert.Empty(t, path) + }) + + t.Run("write VSA fails", func(t *testing.T) { + gen := &mockPredicateGenerator{ + GeneratePredicateFunc: func(ctx context.Context, c applicationsnapshot.Component) (*Predicate, error) { + return pred, nil + }, + } + writer := &mockPredicateWriter{ + WritePredicateFunc: func(p *Predicate) (string, error) { + return "", errors.New("write VSA error") + }, + } + + path, err := GenerateAndWriteVSA(ctx, gen, writer, comp) + assert.Error(t, err) + assert.Contains(t, err.Error(), "write VSA error") + assert.Empty(t, path) + }) +} diff --git a/internal/validate/vsa/vsa.go b/internal/validate/vsa/vsa.go index 13fb063bc..7ac269c3f 100644 --- a/internal/validate/vsa/vsa.go +++ b/internal/validate/vsa/vsa.go @@ -24,11 +24,7 @@ import ( "path/filepath" "time" - "github.com/google/go-containerregistry/pkg/name" - "github.com/sigstore/cosign/v2/pkg/cosign" "github.com/sigstore/cosign/v2/pkg/oci" - "github.com/sigstore/cosign/v2/pkg/oci/static" - "github.com/sigstore/sigstore/pkg/signature" log "github.com/sirupsen/logrus" "github.com/spf13/afero" @@ -48,15 +44,19 @@ type Predicate struct { } // Generator handles VSA predicate generation -type Generator struct{} +type Generator struct { + Report applicationsnapshot.Report +} // NewGenerator creates a new VSA predicate generator -func NewGenerator() *Generator { - return &Generator{} +func NewGenerator(report applicationsnapshot.Report) *Generator { + return &Generator{ + Report: report, + } } // GeneratePredicate creates a Predicate for a validated image/component. -func (g *Generator) GeneratePredicate(ctx context.Context, report applicationsnapshot.Report, comp applicationsnapshot.Component) (*Predicate, error) { +func (g *Generator) GeneratePredicate(ctx context.Context, comp applicationsnapshot.Component) (*Predicate, error) { log.Infof("Generating VSA predicate for image: %s", comp.ContainerImage) // Compose the component info as a map @@ -78,8 +78,8 @@ func (g *Generator) GeneratePredicate(ctx context.Context, report applicationsna } policySource := "" - if report.Policy.Name != "" { - policySource = report.Policy.Name + if g.Report.Policy.Name != "" { + policySource = g.Report.Policy.Name } return &Predicate{ @@ -109,8 +109,8 @@ func NewWriter() *Writer { } } -// WriteVSA writes the Predicate as a JSON file to a temp directory and returns the path. -func (w *Writer) WriteVSA(predicate *Predicate) (string, error) { +// WritePredicate writes the Predicate as a JSON file to a temp directory and returns the path. +func (w *Writer) WritePredicate(predicate *Predicate) (string, error) { log.Infof("Writing VSA for image: %s", predicate.ImageRef) // Serialize with indent @@ -158,41 +158,3 @@ func NoopUploader(ctx context.Context, att oci.Signature, location string) (stri log.Infof("Upload type is 'none'; skipping upload for %s", location) return "", nil } - -type PrivateKeyLoader func(key []byte, pass []byte) (signature.SignerVerifier, error) -type AttestationSigner func(ctx context.Context, signer signature.SignerVerifier, ref name.Reference, att oci.Signature, opts *cosign.CheckOpts) (name.Digest, error) - -type Signer struct { - FS afero.Fs // for reading the VSA file - KeyLoader PrivateKeyLoader // injected loader - SignFunc AttestationSigner // injected cosign API -} - -func NewSigner(fs afero.Fs, loader PrivateKeyLoader, signer AttestationSigner) *Signer { - return &Signer{ - FS: fs, - KeyLoader: loader, - SignFunc: signer, - } -} - -// Sign reads the file, loads the key, and returns the signature. -func (s *Signer) Sign(ctx context.Context, vsaPath, keyPath, imageRef string) (oci.Signature, error) { - log.Infof("Signing VSA for image: %s", imageRef) - vsaData, err := afero.ReadFile(s.FS, vsaPath) - if err != nil { - log.Errorf("Failed to read VSA file: %v", err) - return nil, fmt.Errorf("failed to read VSA file: %w", err) - } - // TODO: Actually sign the attestation using cosign APIs. For now, just create the attestation object. - // Example: - // signer, err := s.KeyLoader( /* load key bytes from keyPath */ ) - // attestationSigned, err := s.SignFunc(ctx, signer, ref, att, nil) - att, err := static.NewAttestation(vsaData) - if err != nil { - log.Errorf("Failed to create attestation: %v", err) - return nil, fmt.Errorf("failed to create attestation: %w", err) - } - log.Infof("VSA attestation (unsigned) created for %s", imageRef) - return att, nil -} diff --git a/internal/validate/vsa/vsa_test.go b/internal/validate/vsa/vsa_test.go index 1888a5b84..b60a31c3d 100644 --- a/internal/validate/vsa/vsa_test.go +++ b/internal/validate/vsa/vsa_test.go @@ -25,14 +25,11 @@ import ( "time" ecapi "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" - "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" v1types "github.com/google/go-containerregistry/pkg/v1/types" appapi "github.com/konflux-ci/application-api/api/v1alpha1" - "github.com/sigstore/cosign/v2/pkg/cosign" "github.com/sigstore/cosign/v2/pkg/cosign/bundle" "github.com/sigstore/cosign/v2/pkg/oci" - "github.com/sigstore/sigstore/pkg/signature" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -41,8 +38,23 @@ import ( "github.com/conforma/cli/internal/evaluator" ) -// TestSignVSA tests the signing functionality of the Signer. +// TestSignVSA tests the signing functionality using the new Signer structure from attest.go. func TestSignVSA(t *testing.T) { + t.Setenv("COSIGN_PASSWORD", "") // key is unencrypted + + // Create test key content + testKey := `-----BEGIN ENCRYPTED SIGSTORE PRIVATE KEY----- +eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjo2NTUzNiwiciI6 +OCwicCI6MX0sInNhbHQiOiJLYU9OQzduQVJLOVgxM1FoaWFucjAwTTBGYys2Sitr +dnAxN1FuanpiVk9nPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94 +Iiwibm9uY2UiOiJVOHZqWWtqMlZOUFZGdlZFZWZ3bXZ5VGloUERrelBoaCJ9LCJj +aXBoZXJ0ZXh0IjoidWNWMnQ4TTZVNFJvb29FOXc0d3dkc3E1RDYrS2RKY245dERT +KzFwRDRGN040SVJOWEgzSTBua3h1a3NackFOUHR1emIvTkVYQ201dUp3Zjh3Qzl1 +VlprbXdwNU5jRUZ6b3ZNS3JCZmNvdXdjaEkrMzkrQ0NhbVZPbzBucmRnZjhvcmpK +dXdrWDBYL1phY0RUTERGaUxyc1laMWVMMmlqMGU1MVRpZmVQNTl4WXNPK1FnM1Jv +OURRVjNQMk9ndDFDaVFHeGg1VXhUZytGc3c9PSJ9 +-----END ENCRYPTED SIGSTORE PRIVATE KEY-----` + // Set up test filesystem fs := afero.NewMemMapFs() @@ -61,41 +73,39 @@ func TestSignVSA(t *testing.T) { RuleResults: []evaluator.Result{{Message: "violation1"}}, } - // Write test file + // Write test files vsaPath := "/test.vsa.json" data, _ := json.Marshal(pred) err := afero.WriteFile(fs, vsaPath, data, 0600) assert.NoError(t, err) - // Create mock key loader and signer - mockKeyLoader := func(key []byte, pass []byte) (signature.SignerVerifier, error) { - return nil, nil // Mock implementation - } + keyPath := "/test.key" + err = afero.WriteFile(fs, keyPath, []byte(testKey), 0600) + assert.NoError(t, err) - mockSigner := func(ctx context.Context, signer signature.SignerVerifier, ref name.Reference, att oci.Signature, opts *cosign.CheckOpts) (name.Digest, error) { - return name.Digest{}, nil // Mock implementation - } + // Test successful signing + t.Run("successful signing", func(t *testing.T) { + signer := testSigner(keyPath, fs) - // Create signer instance - signer := Signer{ - FS: fs, - KeyLoader: mockKeyLoader, - SignFunc: mockSigner, - } + attestor, err := NewAttestor(vsaPath, "quay.io/test/image", "sha256:abcd1234", signer) + require.NoError(t, err) - // Act - sig, err := signer.Sign(context.Background(), vsaPath, "irrelevant.key", "quay.io/test/image:tag") - // Assert - assert.NoError(t, err) - assert.NotNil(t, sig) - payload, err := sig.Payload() - assert.NoError(t, err) - assert.Contains(t, string(payload), "quay.io/test/image:tag") + env, err := attestor.AttestPredicate(context.Background()) + assert.NoError(t, err) + assert.NotEmpty(t, env) + }) - // Test error propagation from attestation creation - _, err = signer.Sign(context.Background(), "/nonexistent/path", "irrelevant.key", "quay.io/test/image:tag") - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to read VSA file") + // Test missing predicate file + t.Run("missing predicate file", func(t *testing.T) { + signer := testSigner(keyPath, fs) + + attestor, err := NewAttestor("/nonexistent.json", "quay.io/test/image", "sha256:abcd1234", signer) + require.NoError(t, err) + + _, err = attestor.AttestPredicate(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "open predicate") + }) } func TestUploadVSAAttestation(t *testing.T) { @@ -175,7 +185,7 @@ func (m *mockAttestation) Size() (int64, error) { func (m *mockAttestation) DiffID() (v1.Hash, error) { return v1.Hash{}, nil } func (m *mockAttestation) MediaType() (v1types.MediaType, error) { return v1types.MediaType(""), nil } -func TestWriteVSA(t *testing.T) { +func TestWritePredicate(t *testing.T) { // Set up test filesystem FS := afero.NewMemMapFs() @@ -205,7 +215,7 @@ func TestWriteVSA(t *testing.T) { } // Write VSA - vsaPath, err := writer.WriteVSA(pred) + vsaPath, err := writer.WritePredicate(pred) require.NoError(t, err) // Verify path format @@ -255,8 +265,8 @@ func TestGeneratePredicate(t *testing.T) { } // Create generator and generate predicate - generator := NewGenerator() - pred, err := generator.GeneratePredicate(context.Background(), report, comp) + generator := NewGenerator(report) + pred, err := generator.GeneratePredicate(context.Background(), comp) require.NoError(t, err) // Verify predicate fields