From 59973fd03b37e64f932987a3bc252ae2c0e5227e Mon Sep 17 00:00:00 2001 From: Joe Stuart Date: Thu, 2 Nov 2023 16:19:14 -0500 Subject: [PATCH] This adds code to generate a VSA https://issues.redhat.com/browse/EC-189 --- .../applicationsnapshot/attestation_test.go | 9 ++ internal/attestation/attestation.go | 21 ++- internal/attestation/slsa_provenance_02.go | 18 ++- internal/attestation/vsa.go | 125 ++++++++++++++++++ internal/definition/validate_test.go | 8 ++ .../application_snapshot_image.go | 13 ++ .../application_snapshot_image_test.go | 12 ++ .../application_snapshot_image/client.go | 15 +++ internal/evaluator/conftest_evaluator.go | 4 + internal/evaluator/evaluator.go | 4 + internal/image/fake.go | 8 ++ internal/image/validate.go | 6 + internal/image/validate_test.go | 4 + internal/output/output.go | 27 ++-- 14 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 internal/attestation/vsa.go diff --git a/internal/applicationsnapshot/attestation_test.go b/internal/applicationsnapshot/attestation_test.go index c1ebeba92..d20ec2bc3 100644 --- a/internal/applicationsnapshot/attestation_test.go +++ b/internal/applicationsnapshot/attestation_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/gkampitakis/go-snaps/snaps" + "github.com/in-toto/in-toto-golang/in_toto" "github.com/stretchr/testify/assert" app "github.com/enterprise-contract/ec-cli/application/v1alpha1" @@ -143,6 +144,14 @@ func (a mockAttestation) Signatures() []signature.EntitySignature { return nil } +func (a mockAttestation) Digest() map[string]string { + return map[string]string{} +} + +func (a mockAttestation) Subject() []in_toto.Subject { + return []in_toto.Subject{} +} + func att(data string) attestation.Attestation { return &mockAttestation{ data: data, diff --git a/internal/attestation/attestation.go b/internal/attestation/attestation.go index 21d4953de..63c1448fa 100644 --- a/internal/attestation/attestation.go +++ b/internal/attestation/attestation.go @@ -38,6 +38,8 @@ type Attestation interface { PredicateType() string Statement() []byte Signatures() []signature.EntitySignature + Subject() []in_toto.Subject + Digest() map[string]string } // Extract the payload from a DSSE signature OCI layer @@ -137,13 +139,20 @@ func ProvenanceFromSignature(sig oci.Signature) (Attestation, error) { return nil, fmt.Errorf("cannot create signed entity: %w", err) } - return provenance{statement: statement, data: embedded, signatures: signatures}, nil + digest, err := sig.Digest() + if err != nil { + return nil, err + } + + return provenance{statement: statement, data: embedded, signatures: signatures, digest: map[string]string{digest.Algorithm: digest.String()}}, nil } type provenance struct { statement in_toto.Statement data []byte signatures []signature.EntitySignature + uri string + digest map[string]string } func (p provenance) Type() string { @@ -162,6 +171,14 @@ func (p provenance) Signatures() []signature.EntitySignature { return p.signatures } +func (p provenance) Digest() map[string]string { + return p.digest +} + +func (p provenance) Subject() []in_toto.Subject { + return p.statement.Subject +} + // Todo: It seems odd that this does not contain the statement. // (See also the equivalent method in slsa_provenance_02.go) func (p provenance) MarshalJSON() ([]byte, error) { @@ -169,10 +186,12 @@ func (p provenance) MarshalJSON() ([]byte, error) { Type string `json:"type"` PredicateType string `json:"predicateType"` Signatures []signature.EntitySignature `json:"signatures"` + Digest map[string]string `json:"digest"` }{ Type: p.Type(), PredicateType: p.PredicateType(), Signatures: p.Signatures(), + Digest: p.Digest(), } return json.Marshal(val) diff --git a/internal/attestation/slsa_provenance_02.go b/internal/attestation/slsa_provenance_02.go index 4cb2134d5..e7814d02c 100644 --- a/internal/attestation/slsa_provenance_02.go +++ b/internal/attestation/slsa_provenance_02.go @@ -64,13 +64,19 @@ func SLSAProvenanceFromSignature(sig oci.Signature) (Attestation, error) { return nil, fmt.Errorf("cannot create signed entity: %w", err) } - return slsaProvenance{statement: statement, data: embedded, signatures: signatures}, nil + digest, err := sig.Digest() + if err != nil { + return nil, err + } + + return slsaProvenance{statement: statement, data: embedded, signatures: signatures, digest: map[string]string{digest.Algorithm: digest.String()}}, nil } type slsaProvenance struct { statement in_toto.ProvenanceStatementSLSA02 data []byte signatures []signature.EntitySignature + digest map[string]string } func (a slsaProvenance) Type() string { @@ -90,6 +96,14 @@ func (a slsaProvenance) Signatures() []signature.EntitySignature { return a.signatures } +func (a slsaProvenance) Digest() map[string]string { + return a.digest +} + +func (a slsaProvenance) Subject() []in_toto.Subject { + return a.statement.Subject +} + // Todo: It seems odd that this does not contain the statement. // (See also the equivalent method in attestation.go) func (a slsaProvenance) MarshalJSON() ([]byte, error) { @@ -98,11 +112,13 @@ func (a slsaProvenance) MarshalJSON() ([]byte, error) { PredicateType string `json:"predicateType"` PredicateBuildType string `json:"predicateBuildType"` Signatures []signature.EntitySignature `json:"signatures"` + Digest map[string]string `json:"digest"` }{ Type: a.statement.Type, PredicateType: a.statement.PredicateType, PredicateBuildType: a.statement.Predicate.BuildType, Signatures: a.signatures, + Digest: a.digest, } return json.Marshal(val) diff --git a/internal/attestation/vsa.go b/internal/attestation/vsa.go new file mode 100644 index 000000000..a08fdb42b --- /dev/null +++ b/internal/attestation/vsa.go @@ -0,0 +1,125 @@ +// Copyright The Enterprise Contract 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 attestation + +import ( + "strings" + "time" + + "github.com/enterprise-contract/ec-cli/internal/evaluator" + "github.com/enterprise-contract/ec-cli/internal/policy" + "github.com/enterprise-contract/ec-cli/internal/policy/source" + "github.com/in-toto/in-toto-golang/in_toto" +) + +const ( + // Make it visible elsewhere + PredicateVSAProvenance = "https://slsa.dev/verification_summary/v1" + StatmentVSA = "https://in-toto.io/Statement/v1" +) + +type ProvenanceStatementVSA struct { + in_toto.StatementHeader + Predicate predicate `json:"predicate"` +} + +type policySource struct { + uri string + digest map[string]string +} + +type attestationSource struct { + uri string + digest map[string]string +} + +type predicate struct { + Verifier map[string]string `json:"verifier"` + TimeVerified string `json:"timeVerified"` + ResourceUri string `json:"resourceUri"` + Policies []policySource `json:"policies"` + InputAttestations []attestationSource `json:"intputAttestations"` + VerificationResult string `json:"verificationResult"` + VerifiedRules []string `json:"verifiedRules"` + VerifiedCollections []string `json:"verfiedCollection"` + SlsaVersion string `json:"slsaVersion"` +} + +func VsaFromImageValidation(results []evaluator.Outcome, policies []source.PolicySource, policy policy.Policy, attestations []Attestation) (ProvenanceStatementVSA, error) { + var verifiedPolicies []policySource + for _, p := range policies { + verifiedPolicies = append(verifiedPolicies, policySource{uri: p.PolicyUrl()}) + } + + var verfiedResults int + var verifiedLevels []string + for _, res := range results { + for _, success := range res.Successes { + verifiedLevels = append(verifiedLevels, success.Metadata["code"].(string)) + } + verfiedResults = verfiedResults + len(res.Failures) + } + verificationResult := "Success" + if verfiedResults > 0 { + verificationResult = "Failure" + } + + var verifiedCollections []string + for _, source := range policy.Spec().Sources { + for _, include := range source.Config.Include { + splitInclude := strings.Split(include, "@") + if len(splitInclude) > 1 { + verifiedCollections = append(verifiedCollections, splitInclude[1]) + } + } + } + + var slsaVersion string + var digest map[string]string + var subject []in_toto.Subject + for _, sp := range attestations { + slsaVersion = sp.PredicateType() + digest = sp.Digest() + subject = sp.Subject() + } + + return ProvenanceStatementVSA{ + StatementHeader: in_toto.StatementHeader{ + Type: StatmentVSA, + PredicateType: PredicateVSAProvenance, + Subject: subject, + }, + Predicate: predicate{ + Verifier: map[string]string{ + "id": "ec", + }, + TimeVerified: time.Now().String(), + // need to check on this. Sounds like it should be the same as the subject, but not compatible types + ResourceUri: subject[0].Name, + Policies: verifiedPolicies, + InputAttestations: []attestationSource{ + { + digest: digest, + }, + }, + VerificationResult: verificationResult, + VerifiedRules: verifiedLevels, + VerifiedCollections: verifiedCollections, + SlsaVersion: slsaVersion, + }, + }, nil +} diff --git a/internal/definition/validate_test.go b/internal/definition/validate_test.go index f43dc9e01..81416e299 100644 --- a/internal/definition/validate_test.go +++ b/internal/definition/validate_test.go @@ -49,6 +49,10 @@ func (e mockEvaluator) CapabilitiesPath() string { return "" } +func (e mockEvaluator) GetPolicySources() []source.PolicySource { + return []source.PolicySource{} +} + func (b badMockEvaluator) Evaluate(ctx context.Context, inputs []string) ([]evaluator.Outcome, evaluator.Data, error) { return nil, nil, errors.New("Evaluator error") } @@ -60,6 +64,10 @@ func (e badMockEvaluator) CapabilitiesPath() string { return "" } +func (e badMockEvaluator) GetPolicySources() []source.PolicySource { + return []source.PolicySource{} +} + func mockNewPipelineDefinitionFile(ctx context.Context, fpath []string, sources []source.PolicySource, namespace []string) (*definition.Definition, error) { return &definition.Definition{ Evaluator: mockEvaluator{}, diff --git a/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go b/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go index 92e60ad58..fe03bacd2 100644 --- a/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go +++ b/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go @@ -71,6 +71,10 @@ type ApplicationSnapshotImage struct { component app.SnapshotComponent } +func (a ApplicationSnapshotImage) GetReference() name.Reference { + return a.reference +} + // NewApplicationSnapshotImage returns an ApplicationSnapshotImage struct with reference, checkOpts, and evaluator ready to use. func NewApplicationSnapshotImage(ctx context.Context, component app.SnapshotComponent, p policy.Policy) (*ApplicationSnapshotImage, error) { opts, err := p.CheckOpts() @@ -233,6 +237,7 @@ func (a *ApplicationSnapshotImage) ValidateAttestationSignature(ctx context.Cont // Set the ClaimVerifier on a shallow *copy* of CheckOpts to avoid unexpected side-effects opts := a.checkOpts opts.ClaimVerifier = cosign.IntotoSubjectClaimVerifier + layers, _, err := NewClient(ctx).VerifyImageAttestations(ctx, a.reference, &opts) if err != nil { return err @@ -333,6 +338,14 @@ func (a *ApplicationSnapshotImage) Signatures() []signature.EntitySignature { return a.signatures } +func (a *ApplicationSnapshotImage) ResolveDigest(ctx context.Context) (string, error) { + digest, err := NewClient(ctx).ResolveDigest(a.reference, &a.checkOpts) + if err != nil { + return "", err + } + return digest, nil +} + type attestationData struct { json.RawMessage // Deprecated Extra attestationExtraData `json:"extra"` // Deprecated diff --git a/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go b/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go index 1e7acc197..d9ed16229 100644 --- a/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go +++ b/internal/evaluation_target/application_snapshot_image/application_snapshot_image_test.go @@ -147,6 +147,14 @@ func (f fakeAtt) Signatures() []signature.EntitySignature { return f.signatures } +func (f fakeAtt) Digest() map[string]string { + return map[string]string{} +} + +func (f fakeAtt) Subject() []in_toto.Subject { + return []in_toto.Subject{} +} + type opts func(*fakeAtt) func createSimpleAttestation(statement *in_toto.ProvenanceStatementSLSA02, o ...opts) attestation.Attestation { @@ -443,6 +451,10 @@ func (c *MockClient) Head(name name.Reference, options ...remote.Option) (*v1.De return args.Get(0).(*v1.Descriptor), args.Error(1) } +func (c *MockClient) ResolveDigest(ref name.Reference, opts *cosign.CheckOpts) (string, error) { + return "", nil +} + func TestValidateImageSignatureClaims(t *testing.T) { ref := name.MustParseReference("registry.io/repository/image:tag") a := ApplicationSnapshotImage{ diff --git a/internal/evaluation_target/application_snapshot_image/client.go b/internal/evaluation_target/application_snapshot_image/client.go index 11f80944b..cd1ebbba5 100644 --- a/internal/evaluation_target/application_snapshot_image/client.go +++ b/internal/evaluation_target/application_snapshot_image/client.go @@ -21,9 +21,11 @@ import ( "github.com/google/go-containerregistry/pkg/name" gcr "github.com/google/go-containerregistry/pkg/v1" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/sigstore/cosign/v2/pkg/cosign" "github.com/sigstore/cosign/v2/pkg/oci" + ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote" ) type contextKey string @@ -36,6 +38,7 @@ type Client interface { VerifyImageSignatures(context.Context, name.Reference, *cosign.CheckOpts) ([]oci.Signature, bool, error) VerifyImageAttestations(context.Context, name.Reference, *cosign.CheckOpts) ([]oci.Signature, bool, error) Head(name.Reference, ...remote.Option) (*gcr.Descriptor, error) + ResolveDigest(name.Reference, *cosign.CheckOpts) (string, error) } func WithClient(ctx context.Context, client Client) context.Context { @@ -66,3 +69,15 @@ func (c *defaultClient) VerifyImageAttestations(ctx context.Context, ref name.Re func (c *defaultClient) Head(ref name.Reference, opts ...remote.Option) (*gcr.Descriptor, error) { return remote.Head(ref, opts...) } + +func (c *defaultClient) ResolveDigest(ref name.Reference, opts *cosign.CheckOpts) (string, error) { + digest, err := ociremote.ResolveDigest(ref, opts.RegistryClientOpts...) + if err != nil { + return "", err + } + h, err := v1.NewHash(digest.Identifier()) + if err != nil { + return "", err + } + return h.String(), nil +} diff --git a/internal/evaluator/conftest_evaluator.go b/internal/evaluator/conftest_evaluator.go index 74f3a940c..cae6f72e6 100644 --- a/internal/evaluator/conftest_evaluator.go +++ b/internal/evaluator/conftest_evaluator.go @@ -260,6 +260,10 @@ func NewConftestEvaluatorWithNamespace(ctx context.Context, policySources []sour return c, nil } +func (c conftestEvaluator) GetPolicySources() []source.PolicySource { + return c.policySources +} + // Destroy removes the working directory func (c conftestEvaluator) Destroy() { if os.Getenv("EC_DEBUG") == "" { diff --git a/internal/evaluator/evaluator.go b/internal/evaluator/evaluator.go index a088bbdc6..0da5f6614 100644 --- a/internal/evaluator/evaluator.go +++ b/internal/evaluator/evaluator.go @@ -18,6 +18,8 @@ package evaluator import ( "context" + + "github.com/enterprise-contract/ec-cli/internal/policy/source" ) type Evaluator interface { @@ -28,6 +30,8 @@ type Evaluator interface { // CapabilitiesPath returns the path to the file where capabilities are defined CapabilitiesPath() string + + GetPolicySources() []source.PolicySource } type Data map[string]any diff --git a/internal/image/fake.go b/internal/image/fake.go index f2b7d5b48..603f1df92 100644 --- a/internal/image/fake.go +++ b/internal/image/fake.go @@ -48,3 +48,11 @@ func (f fakeAtt) PredicateType() string { func (f fakeAtt) Signatures() []signature.EntitySignature { return []signature.EntitySignature{} } + +func (f fakeAtt) Digest() map[string]string { + return map[string]string{} +} + +func (f fakeAtt) Subject() []in_toto.Subject { + return []in_toto.Subject{} +} diff --git a/internal/image/validate.go b/internal/image/validate.go index 88111017d..2dd57de69 100644 --- a/internal/image/validate.go +++ b/internal/image/validate.go @@ -40,6 +40,7 @@ func ValidateImage(ctx context.Context, comp app.SnapshotComponent, p policy.Pol out := &output.Output{ImageURL: comp.ContainerImage, Detailed: detailed, Policy: p} a, err := application_snapshot_image.NewApplicationSnapshotImage(ctx, comp, p) + if err != nil { log.Debug("Failed to create application snapshot image!") return nil, err @@ -117,6 +118,11 @@ func ValidateImage(ctx context.Context, comp app.SnapshotComponent, p policy.Pol } allResults = append(allResults, results...) out.Data = append(out.Data, data) + vsa, err := attestation.VsaFromImageValidation(results, e.GetPolicySources(), p, a.Attestations()) + if err != nil { + log.Debugf("error creating vsa: %v", err) + } + out.Vsa = append(out.Vsa, vsa) } out.PolicyInput = inputJSON diff --git a/internal/image/validate_test.go b/internal/image/validate_test.go index a2e640908..343b6faa6 100644 --- a/internal/image/validate_test.go +++ b/internal/image/validate_test.go @@ -225,6 +225,10 @@ func (c *mockASIClient) Head(ref name.Reference, opts ...remote.Option) (*gcr.De return c.head, nil } +func (c *mockASIClient) ResolveDigest(ref name.Reference, opts *cosign.CheckOpts) (string, error) { + return "", nil +} + func sign(statement *in_toto.Statement) oci.Signature { statementJson, err := json.Marshal(statement) if err != nil { diff --git a/internal/output/output.go b/internal/output/output.go index 6d00805e4..deed83c24 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -75,19 +75,20 @@ func (v VerificationStatus) addToSuccesses(successes []evaluator.Result) []evalu // Output is a struct representing checks and exit code. type Output struct { - ImageAccessibleCheck VerificationStatus `json:"imageAccessibleCheck"` - ImageSignatureCheck VerificationStatus `json:"imageSignatureCheck"` - AttestationSignatureCheck VerificationStatus `json:"attestationSignatureCheck"` - AttestationSyntaxCheck VerificationStatus `json:"attestationSyntaxCheck"` - PolicyCheck []evaluator.Outcome `json:"policyCheck"` - ExitCode int `json:"-"` - Signatures []signature.EntitySignature `json:"signatures,omitempty"` - Attestations []attestation.Attestation `json:"attestations,omitempty"` - ImageURL string `json:"-"` - Detailed bool `json:"-"` - Data []evaluator.Data `json:"-"` - Policy policy.Policy `json:"-"` - PolicyInput []byte `json:"-"` + ImageAccessibleCheck VerificationStatus `json:"imageAccessibleCheck"` + ImageSignatureCheck VerificationStatus `json:"imageSignatureCheck"` + AttestationSignatureCheck VerificationStatus `json:"attestationSignatureCheck"` + AttestationSyntaxCheck VerificationStatus `json:"attestationSyntaxCheck"` + PolicyCheck []evaluator.Outcome `json:"policyCheck"` + ExitCode int `json:"-"` + Signatures []signature.EntitySignature `json:"signatures,omitempty"` + Attestations []attestation.Attestation `json:"attestations,omitempty"` + ImageURL string `json:"-"` + Detailed bool `json:"-"` + Data []evaluator.Data `json:"-"` + Policy policy.Policy `json:"-"` + PolicyInput []byte `json:"-"` + Vsa []attestation.ProvenanceStatementVSA `json:"vsa"` } // SetImageAccessibleCheck sets the passed and result.message fields of the ImageAccessibleCheck to the given values.