diff --git a/app/cli/api/attestation/v1/crafting_state.go b/app/cli/api/attestation/v1/crafting_state.go new file mode 100644 index 000000000..1b948098e --- /dev/null +++ b/app/cli/api/attestation/v1/crafting_state.go @@ -0,0 +1,41 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// 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. + +package v1 + +import ( + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" +) + +type NormalizedMaterialOutput struct { + Value, Digest string + IsOutput bool +} + +func (m *Attestation_Material) NormalizedOutput() *NormalizedMaterialOutput { + switch m.MaterialType { + case schemaapi.CraftingSchema_Material_ARTIFACT, schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON, schemaapi.CraftingSchema_Material_SBOM_SPDX_JSON: + a := m.GetArtifact() + return &NormalizedMaterialOutput{a.Name, a.Digest, a.IsSubject} + case schemaapi.CraftingSchema_Material_CONTAINER_IMAGE: + a := m.GetContainerImage() + return &NormalizedMaterialOutput{a.Name, a.Digest, a.IsSubject} + case schemaapi.CraftingSchema_Material_STRING: + a := m.GetString_() + return &NormalizedMaterialOutput{Value: a.Value} + } + + return nil +} diff --git a/app/cli/internal/action/workflow_run_describe.go b/app/cli/internal/action/workflow_run_describe.go index 95cbc8182..3557c7be8 100644 --- a/app/cli/internal/action/workflow_run_describe.go +++ b/app/cli/internal/action/workflow_run_describe.go @@ -23,7 +23,7 @@ import ( "time" pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" - "github.com/chainloop-dev/chainloop/internal/attestation/renderer" + "github.com/chainloop-dev/chainloop/internal/attestation/renderer/chainloop" sigs "github.com/sigstore/cosign/v2/pkg/signature" "github.com/in-toto/in-toto-golang/in_toto" @@ -47,7 +47,7 @@ type WorkflowRunAttestationItem struct { CreatedAt *time.Time `json:"createdAt"` Envelope *dsse.Envelope `json:"envelope"` statement *in_toto.Statement - predicateV1 *renderer.ChainloopProvenancePredicateV1 + predicateV1 *chainloop.ProvenancePredicateV01 Materials []*Material `json:"materials,omitempty"` EnvVars []*EnvVar `json:"envvars,omitempty"` } @@ -63,7 +63,7 @@ type EnvVar struct { Value string `json:"value"` } -func (i *WorkflowRunAttestationItem) Predicate() *renderer.ChainloopProvenancePredicateV1 { +func (i *WorkflowRunAttestationItem) Predicate() *chainloop.ProvenancePredicateV01 { return i.predicateV1 } @@ -125,8 +125,8 @@ func (action *WorkflowRunDescribe) Run(runID string, verify bool, publicKey stri return nil, fmt.Errorf("un-marshaling predicate: %w", err) } - var predicate *renderer.ChainloopProvenancePredicateV1 - if statement.PredicateType == renderer.ChainloopPredicateTypeV1 { + var predicate *chainloop.ProvenancePredicateV01 + if statement.PredicateType == chainloop.PredicateTypeV01 { if predicate, err = extractPredicateV1(statement); err != nil { return nil, fmt.Errorf("extracting predicate: %w", err) } @@ -156,13 +156,13 @@ func (action *WorkflowRunDescribe) Run(runID string, verify bool, publicKey stri return item, nil } -func extractPredicateV1(statement *in_toto.Statement) (*renderer.ChainloopProvenancePredicateV1, error) { +func extractPredicateV1(statement *in_toto.Statement) (*chainloop.ProvenancePredicateV01, error) { jsonPredicate, err := json.Marshal(statement.Predicate) if err != nil { return nil, fmt.Errorf("un-marshaling predicate: %w", err) } - predicate := &renderer.ChainloopProvenancePredicateV1{} + predicate := &chainloop.ProvenancePredicateV01{} if err := json.Unmarshal(jsonPredicate, predicate); err != nil { return nil, fmt.Errorf("un-marshaling predicate: %w", err) } diff --git a/app/controlplane/internal/biz/integration/dependencytrack/dependencytrack.go b/app/controlplane/internal/biz/integration/dependencytrack/dependencytrack.go index c3c5f3ebb..1737e64e6 100644 --- a/app/controlplane/internal/biz/integration/dependencytrack/dependencytrack.go +++ b/app/controlplane/internal/biz/integration/dependencytrack/dependencytrack.go @@ -28,11 +28,11 @@ import ( contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/app/controlplane/internal/biz" "github.com/chainloop-dev/chainloop/app/controlplane/internal/integrations/dependencytrack" - "github.com/chainloop-dev/chainloop/internal/attestation/renderer" + "github.com/chainloop-dev/chainloop/internal/attestation/renderer/chainloop" "github.com/chainloop-dev/chainloop/internal/credentials" "github.com/chainloop-dev/chainloop/internal/servicelogger" + errors "github.com/go-kratos/kratos/v2/errors" "github.com/go-kratos/kratos/v2/log" - "github.com/go-openapi/errors" "github.com/secure-systems-lab/go-securesystemslib/dsse" ) @@ -106,11 +106,16 @@ func (uc *Integration) UploadSBOMs(envelope *dsse.Envelope, orgID, workflowID st } // There is at least one enabled integration, extract the SBOMs - predicate, err := renderer.ExtractPredicate(envelope) + predicates, err := chainloop.ExtractPredicate(envelope) if err != nil { return err } + predicate := predicates.V01 + if predicate == nil { + return errors.Forbidden("not implemented", "only v0.1 predicate is supported for now") + } + repo, err := uc.ociUC.FindMainRepo(ctx, orgID) if err != nil { return err diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index 46821853e..9a27eea50 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -28,7 +28,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/internal/biz" "github.com/chainloop-dev/chainloop/app/controlplane/internal/biz/integration/dependencytrack" "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext" - "github.com/chainloop-dev/chainloop/internal/attestation/renderer" + "github.com/chainloop-dev/chainloop/internal/attestation/renderer/chainloop" "github.com/chainloop-dev/chainloop/internal/credentials" casJWT "github.com/chainloop-dev/chainloop/internal/robotaccount/cas" sl "github.com/chainloop-dev/chainloop/internal/servicelogger" @@ -282,11 +282,16 @@ func bizAttestationToPb(att *biz.Attestation) (*cpAPI.AttestationItem, error) { return nil, err } - predicate, err := renderer.ExtractPredicate(att.Envelope) + predicates, err := chainloop.ExtractPredicate(att.Envelope) if err != nil { return nil, err } + predicate := predicates.V01 + if predicate == nil { + return nil, errors.InternalServer("invalid attestation type", "attestation does not contain a V01 predicate") + } + return &cpAPI.AttestationItem{ Envelope: encodedAttestation, EnvVars: extractEnvVariables(predicate.Env), @@ -308,7 +313,7 @@ func extractEnvVariables(in map[string]string) []*cpAPI.AttestationItem_EnvVaria return res } -func extractMaterials(in []*renderer.ChainloopProvenanceMaterial) []*cpAPI.AttestationItem_Material { +func extractMaterials(in []*chainloop.ProvenanceMaterial) []*cpAPI.AttestationItem_Material { res := make([]*cpAPI.AttestationItem_Material, 0, len(in)) for _, m := range in { res = append(res, &cpAPI.AttestationItem_Material{Name: m.Name, Value: m.Material.String(), Type: m.Type}) diff --git a/go.mod b/go.mod index 1bc5fe770..1139d036e 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,6 @@ require ( github.com/getsentry/sentry-go v0.17.0 github.com/go-kratos/kratos/contrib/log/zap/v2 v2.0.0-20230113095809-bebea0c103a8 github.com/go-kratos/kratos/v2 v2.5.3 - github.com/go-openapi/errors v0.20.3 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/go-containerregistry v0.14.1-0.20230409045903-ed5c185df419 @@ -110,6 +109,7 @@ require ( github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.21.4 // indirect + github.com/go-openapi/errors v0.20.3 // indirect github.com/go-openapi/inflect v0.19.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect diff --git a/internal/attestation/renderer/chainloop.go b/internal/attestation/renderer/chainloop.go deleted file mode 100644 index 595245097..000000000 --- a/internal/attestation/renderer/chainloop.go +++ /dev/null @@ -1,272 +0,0 @@ -// -// Copyright 2023 The Chainloop Authors. -// -// 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. - -package renderer - -import ( - "crypto/sha256" - "encoding/json" - "fmt" - "sort" - "strings" - "time" - - v1 "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1" - "github.com/in-toto/in-toto-golang/in_toto" - "github.com/secure-systems-lab/go-securesystemslib/dsse" - - schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" - slsacommon "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" -) - -const ChainloopPredicateTypeV1 = "chainloop.dev/attestation/v0.1" - -// TODO: Figure out a more appropriate meaning -const chainloopBuildType = "chainloop.dev/workflowrun/v0.1" - -const builderIDFmt = "chainloop.dev/cli/%s@%s" - -type ChainloopProvenancePredicateV1 struct { - Metadata *ChainloopMetadata `json:"metadata"` - Materials []*ChainloopProvenanceMaterial `json:"materials,omitempty"` - Builder *slsacommon.ProvenanceBuilder `json:"builder"` - BuildType string `json:"buildType"` - Env map[string]string `json:"env,omitempty"` - RunnerType string `json:"runnerType"` - RunnerURL string `json:"runnerURL,omitempty"` -} - -type ChainloopProvenanceMaterial struct { - Name string `json:"name"` - Type string `json:"type"` - Material *ChainloopProvenanceM `json:"material"` -} - -type SLSACommonProvenanceMaterial struct { - *slsacommon.ProvenanceMaterial -} - -func (m *SLSACommonProvenanceMaterial) String() (res string) { - // we just care about the first one - for alg, h := range m.Digest { - res = fmt.Sprintf("%s@%s:%s", m.URI, alg, h) - } - - return -} - -func (m *ChainloopProvenanceM) String() string { - if m.SLSA != nil { - return m.SLSA.String() - } - - return m.StringVal -} - -type ChainloopProvenanceM struct { - SLSA *SLSACommonProvenanceMaterial `json:"slsa,omitempty"` - StringVal string `json:"stringVal,omitempty"` -} - -type ChainloopMetadata struct { - Name string `json:"name"` - Project string `json:"project"` - Team string `json:"team"` - InitializedAt *time.Time `json:"initializedAt"` - FinishedAt *time.Time `json:"finishedAt"` - WorkflowRunID string `json:"workflowRunID"` - WorkflowID string `json:"workflowID"` -} - -type ChainloopMaintainer struct { - Name string `json:"name"` - Email string `json:"email"` -} - -type ChainloopRenderer struct { - att *v1.Attestation - builder *builderInfo -} - -type builderInfo struct { - version, digest string -} - -func newChainloopRenderer(att *v1.Attestation, builderVersion, builderDigest string) *ChainloopRenderer { - return &ChainloopRenderer{att, &builderInfo{builderVersion, builderDigest}} -} - -func (r *ChainloopRenderer) Predicate() (interface{}, error) { - return ChainloopProvenancePredicateV1{ - Materials: outputChainloopMaterials(r.att, false), - BuildType: chainloopBuildType, - Builder: &slsacommon.ProvenanceBuilder{ID: fmt.Sprintf(builderIDFmt, r.builder.version, r.builder.digest)}, - Metadata: getChainloopMeta(r.att), - Env: r.att.EnvVars, - RunnerType: r.att.GetRunnerType().String(), - RunnerURL: r.att.GetRunnerUrl(), - }, nil -} - -func getChainloopMeta(att *v1.Attestation) *ChainloopMetadata { - initializedAt := att.InitializedAt.AsTime() - wfMeta := att.GetWorkflow() - - // Finished at is set at the time of render - finishedAt := time.Now() - - return &ChainloopMetadata{ - InitializedAt: &initializedAt, - FinishedAt: &finishedAt, - Name: wfMeta.GetName(), - Team: wfMeta.GetTeam(), - Project: wfMeta.GetProject(), - WorkflowRunID: wfMeta.GetWorkflowRunId(), - WorkflowID: wfMeta.GetWorkflowId(), - } -} - -func (r *ChainloopRenderer) Header() (*in_toto.StatementHeader, error) { - raw, err := json.Marshal(r.att) - if err != nil { - return nil, err - } - - // We might don't want this and just force the existence of one material with output = true - subjects := []in_toto.Subject{ - { - Name: fmt.Sprintf("chainloop.dev/workflow/%s", r.att.GetWorkflow().Name), - Digest: map[string]string{ - "sha256": fmt.Sprintf("%x", sha256.Sum256(raw)), - }, - }, - } - - for _, m := range outputChainloopMaterials(r.att, true) { - if slsaMaterial := m.Material.SLSA.ProvenanceMaterial; slsaMaterial != nil { - subjects = append(subjects, in_toto.Subject{ - Name: slsaMaterial.URI, - Digest: slsaMaterial.Digest, - }) - } - } - - return &in_toto.StatementHeader{ - Type: in_toto.StatementInTotoV01, - PredicateType: ChainloopPredicateTypeV1, - Subject: subjects, - }, nil -} - -func outputChainloopMaterials(att *v1.Attestation, onlyOutput bool) []*ChainloopProvenanceMaterial { - // Sort material keys to stabilize output - keys := make([]string, 0, len(att.GetMaterials())) - for k := range att.GetMaterials() { - keys = append(keys, k) - } - - sort.Strings(keys) - - res := []*ChainloopProvenanceMaterial{} - materials := att.GetMaterials() - for _, mdefName := range keys { - mdef := materials[mdefName] - - var value, digest string - artifactType := mdef.MaterialType - var isOutput bool - - switch mdef.MaterialType { - case schemaapi.CraftingSchema_Material_ARTIFACT, schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON, schemaapi.CraftingSchema_Material_SBOM_SPDX_JSON: - a := mdef.GetArtifact() - value, digest, isOutput = a.Name, a.Digest, a.IsSubject - case schemaapi.CraftingSchema_Material_CONTAINER_IMAGE: - a := mdef.GetContainerImage() - value, digest, isOutput = a.Name, a.Digest, a.IsSubject - case schemaapi.CraftingSchema_Material_STRING: - a := mdef.GetString_() - value = a.Value - } - - // Skip if we are expecting to show only the materials marked as output - if onlyOutput && !isOutput { - continue - } - - material := &ChainloopProvenanceM{} - if artifactType == schemaapi.CraftingSchema_Material_STRING { - material.StringVal = value - } else if digest != "" { - parts := strings.Split(digest, ":") - material.SLSA = &SLSACommonProvenanceMaterial{ - &slsacommon.ProvenanceMaterial{ - URI: value, - Digest: map[string]string{ - parts[0]: parts[1], - }, - }, - } - } - - res = append(res, &ChainloopProvenanceMaterial{ - Material: material, - Name: mdefName, - Type: artifactType.String(), - }) - } - - return res -} - -// Extract the Chainloop attestation predicate from an encoded DSSE envelope -func ExtractPredicate(envelope *dsse.Envelope) (*ChainloopProvenancePredicateV1, error) { - decodedPayload, err := envelope.DecodeB64Payload() - if err != nil { - return nil, err - } - - // 1 - Extract the in-toto statement - statement := &in_toto.Statement{} - if err := json.Unmarshal(decodedPayload, statement); err != nil { - return nil, fmt.Errorf("un-marshaling predicate: %w", err) - } - - // 2 - Extract the Chainloop predicate from the in-toto statement - var predicate *ChainloopProvenancePredicateV1 - switch statement.PredicateType { - case ChainloopPredicateTypeV1: - if predicate, err = extractPredicateV1(statement); err != nil { - return nil, fmt.Errorf("extracting predicate: %w", err) - } - default: - return nil, fmt.Errorf("unsupported predicate type: %s", statement.PredicateType) - } - - return predicate, nil -} - -func extractPredicateV1(statement *in_toto.Statement) (*ChainloopProvenancePredicateV1, error) { - jsonPredicate, err := json.Marshal(statement.Predicate) - if err != nil { - return nil, fmt.Errorf("un-marshaling predicate: %w", err) - } - - predicate := &ChainloopProvenancePredicateV1{} - if err := json.Unmarshal(jsonPredicate, predicate); err != nil { - return nil, fmt.Errorf("un-marshaling predicate: %w", err) - } - - return predicate, nil -} diff --git a/internal/attestation/renderer/chainloop/chainloop.go b/internal/attestation/renderer/chainloop/chainloop.go new file mode 100644 index 000000000..b7de49792 --- /dev/null +++ b/internal/attestation/renderer/chainloop/chainloop.go @@ -0,0 +1,115 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// 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. + +package chainloop + +import ( + "encoding/json" + "fmt" + "time" + + v1 "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1" + + "github.com/in-toto/in-toto-golang/in_toto" + slsacommon "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" +) + +// // Replace custom material type with https://github.com/in-toto/attestation/blob/main/spec/v1.0/resource_descriptor.md +// const ChainloopPredicateTypeV02 = "chainloop.dev/attestation/v0.2" + +// TODO: Figure out a more appropriate meaning +const chainloopBuildType = "chainloop.dev/workflowrun/v0.1" + +const builderIDFmt = "chainloop.dev/cli/%s@%s" + +type ProvenancePredicateVersions struct { + V01 *ProvenancePredicateV01 +} + +type ProvenancePredicateCommon struct { + Metadata *Metadata `json:"metadata"` + Builder *slsacommon.ProvenanceBuilder `json:"builder"` + BuildType string `json:"buildType"` + Env map[string]string `json:"env,omitempty"` + RunnerType string `json:"runnerType"` + RunnerURL string `json:"runnerURL,omitempty"` +} + +type Metadata struct { + Name string `json:"name"` + Project string `json:"project"` + Team string `json:"team"` + InitializedAt *time.Time `json:"initializedAt"` + FinishedAt *time.Time `json:"finishedAt"` + WorkflowRunID string `json:"workflowRunID"` + WorkflowID string `json:"workflowID"` +} + +type Maintainer struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type builderInfo struct { + version, digest string +} + +type RendererCommon struct { + predicateType string + att *v1.Attestation + builder *builderInfo +} + +func predicateCommon(builderInfo *builderInfo, att *v1.Attestation) *ProvenancePredicateCommon { + return &ProvenancePredicateCommon{ + BuildType: chainloopBuildType, + Builder: &slsacommon.ProvenanceBuilder{ID: fmt.Sprintf(builderIDFmt, builderInfo.version, builderInfo.digest)}, + Metadata: getChainloopMeta(att), + Env: att.EnvVars, + RunnerType: att.GetRunnerType().String(), + RunnerURL: att.GetRunnerUrl(), + } +} + +func getChainloopMeta(att *v1.Attestation) *Metadata { + initializedAt := att.InitializedAt.AsTime() + wfMeta := att.GetWorkflow() + + // Finished at is set at the time of render + finishedAt := time.Now() + + return &Metadata{ + InitializedAt: &initializedAt, + FinishedAt: &finishedAt, + Name: wfMeta.GetName(), + Team: wfMeta.GetTeam(), + Project: wfMeta.GetProject(), + WorkflowRunID: wfMeta.GetWorkflowRunId(), + WorkflowID: wfMeta.GetWorkflowId(), + } +} + +func extractPredicate(statement *in_toto.Statement, v any) error { + jsonPredicate, err := json.Marshal(statement.Predicate) + if err != nil { + return fmt.Errorf("un-marshaling predicate: %w", err) + } + + if err := json.Unmarshal(jsonPredicate, v); err != nil { + return fmt.Errorf("un-marshaling predicate: %w", err) + } + + return nil +} diff --git a/internal/attestation/renderer/testdata/attestation.output.v0.1.json b/internal/attestation/renderer/chainloop/testdata/attestation.output.v0.1.json similarity index 100% rename from internal/attestation/renderer/testdata/attestation.output.v0.1.json rename to internal/attestation/renderer/chainloop/testdata/attestation.output.v0.1.json diff --git a/internal/attestation/renderer/chainloop/testdata/attestation.output.v0.2.json b/internal/attestation/renderer/chainloop/testdata/attestation.output.v0.2.json new file mode 100644 index 000000000..af64e5f0b --- /dev/null +++ b/internal/attestation/renderer/chainloop/testdata/attestation.output.v0.2.json @@ -0,0 +1,73 @@ +{ + "_type": "https://in-toto.io/Statement/v0.1", + "predicateType": "chainloop.dev/attestation/v0.2", + "subject": [ + { + "name": "chainloop.dev/workflow/foo", + "digest": { + "sha256": "6cd649c6105e12a235510a585371eb69c8c9ee797d8dc80d30695828ca116b00" + } + }, + { + "name": "index.docker.io/bitnami/nginx", + "digest": { + "sha256": "580ac09da7771920dfd0c214964e7bfe4c27903bcbe075769a4044a67c9a390a" + } + } + ], + "predicate": { + "metadata": { + "name": "foo", + "project": "bar", + "team": "", + "initializedAt": "2023-05-03T17:22:12.743426076Z", + "finishedAt": "2023-05-03T19:27:51.352850152+02:00", + "workflowRunID": "", + "workflowID": "54ea7c5c-7592-48ac-9a9f-084b72447184" + }, + "materials": [ + { + "content": "YS1zdHJpbmc=", + "annotations": { + "chainloop.material.name": "build-ref", + "chainloop.material.type": "STRING" + } + }, + { + "digest": { + "sha256": "cfc7d8e24d21ade921d720228ad1693de59dab45ff679606940be75b7bf660dc" + }, + "name": "Makefile", + "annotations": { + "chainloop.material.name": "rootfs", + "chainloop.material.type": "ARTIFACT" + } + }, + { + "digest": { + "sha256": "580ac09da7771920dfd0c214964e7bfe4c27903bcbe075769a4044a67c9a390a" + }, + "name": "index.docker.io/bitnami/nginx", + "annotations": { + "chainloop.material.name": "skynet-control-plane", + "chainloop.material.type": "CONTAINER_IMAGE" + } + }, + { + "digest": { + "sha256": "16159bb881eb4ab7eb5d8afc5350b0feeed1e31c0a268e355e74f9ccbe885e0c" + }, + "name": "sbom.cyclonedx.json", + "annotations": { + "chainloop.material.name": "skynet-sbom", + "chainloop.material.type": "SBOM_CYCLONEDX_JSON" + } + } + ], + "builder": { + "id": "chainloop.dev/cli/dev@sha256:59e14f1a9de709cdd0e91c36b33e54fcca95f7dba1dc7169a7f81986e02108e5" + }, + "buildType": "chainloop.dev/workflowrun/v0.1", + "runnerType": "GITHUB_ACTION" + } +} \ No newline at end of file diff --git a/internal/attestation/renderer/testdata/attestation.source.json b/internal/attestation/renderer/chainloop/testdata/attestation.source.json similarity index 100% rename from internal/attestation/renderer/testdata/attestation.source.json rename to internal/attestation/renderer/chainloop/testdata/attestation.source.json diff --git a/internal/attestation/renderer/testdata/unknown.envelope.json b/internal/attestation/renderer/chainloop/testdata/unknown.envelope.json similarity index 100% rename from internal/attestation/renderer/testdata/unknown.envelope.json rename to internal/attestation/renderer/chainloop/testdata/unknown.envelope.json diff --git a/internal/attestation/renderer/testdata/valid.envelope.json b/internal/attestation/renderer/chainloop/testdata/valid.envelope.json similarity index 100% rename from internal/attestation/renderer/testdata/valid.envelope.json rename to internal/attestation/renderer/chainloop/testdata/valid.envelope.json diff --git a/internal/attestation/renderer/testdata/valid.predicate.json b/internal/attestation/renderer/chainloop/testdata/valid.predicate.json similarity index 100% rename from internal/attestation/renderer/testdata/valid.predicate.json rename to internal/attestation/renderer/chainloop/testdata/valid.predicate.json diff --git a/internal/attestation/renderer/chainloop/v01.go b/internal/attestation/renderer/chainloop/v01.go new file mode 100644 index 000000000..e6f252e2f --- /dev/null +++ b/internal/attestation/renderer/chainloop/v01.go @@ -0,0 +1,193 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// 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. + +package chainloop + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + "strings" + + v1 "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1" + "github.com/in-toto/in-toto-golang/in_toto" + "github.com/secure-systems-lab/go-securesystemslib/dsse" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + slsacommon "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" +) + +const PredicateTypeV01 = "chainloop.dev/attestation/v0.1" + +type ProvenancePredicateV01 struct { + *ProvenancePredicateCommon + Materials []*ProvenanceMaterial `json:"materials,omitempty"` +} + +type ProvenanceMaterial struct { + Name string `json:"name"` + Type string `json:"type"` + Material *ProvenanceM `json:"material"` +} + +type SLSACommonProvenanceMaterial struct { + *slsacommon.ProvenanceMaterial +} + +func (m *SLSACommonProvenanceMaterial) String() (res string) { + // we just care about the first one + for alg, h := range m.Digest { + res = fmt.Sprintf("%s@%s:%s", m.URI, alg, h) + } + + return +} + +func (m *ProvenanceM) String() string { + if m.SLSA != nil { + return m.SLSA.String() + } + + return m.StringVal +} + +type ProvenanceM struct { + SLSA *SLSACommonProvenanceMaterial `json:"slsa,omitempty"` + StringVal string `json:"stringVal,omitempty"` +} + +type RendererV01 struct { + *RendererCommon +} + +func NewChainloopRendererV01(att *v1.Attestation, builderVersion, builderDigest string) *RendererV01 { + return &RendererV01{&RendererCommon{ + PredicateTypeV01, att, &builderInfo{builderVersion, builderDigest}}, + } +} + +func (r *RendererV01) Predicate() (interface{}, error) { + return ProvenancePredicateV01{ + ProvenancePredicateCommon: predicateCommon(r.builder, r.att), + Materials: outputChainloopMaterials(r.att, false), + }, nil +} + +func (r *RendererV01) Header() (*in_toto.StatementHeader, error) { + raw, err := json.Marshal(r.att) + if err != nil { + return nil, err + } + + // We might don't want this and just force the existence of one material with output = true + subjects := []in_toto.Subject{ + { + Name: fmt.Sprintf("chainloop.dev/workflow/%s", r.att.GetWorkflow().Name), + Digest: map[string]string{ + "sha256": fmt.Sprintf("%x", sha256.Sum256(raw)), + }, + }, + } + + for _, m := range outputChainloopMaterials(r.att, true) { + if slsaMaterial := m.Material.SLSA.ProvenanceMaterial; slsaMaterial != nil { + subjects = append(subjects, in_toto.Subject{ + Name: slsaMaterial.URI, + Digest: slsaMaterial.Digest, + }) + } + } + + return &in_toto.StatementHeader{ + Type: in_toto.StatementInTotoV01, + PredicateType: r.predicateType, + Subject: subjects, + }, nil +} + +func outputChainloopMaterials(att *v1.Attestation, onlyOutput bool) []*ProvenanceMaterial { + // Sort material keys to stabilize output + keys := make([]string, 0, len(att.GetMaterials())) + for k := range att.GetMaterials() { + keys = append(keys, k) + } + + sort.Strings(keys) + + res := []*ProvenanceMaterial{} + materials := att.GetMaterials() + for _, mdefName := range keys { + mdef := materials[mdefName] + + artifactType := mdef.MaterialType + nMaterial := mdef.NormalizedOutput() + + // Skip if we are expecting to show only the materials marked as output + if onlyOutput && !nMaterial.IsOutput { + continue + } + + material := &ProvenanceM{} + if artifactType == schemaapi.CraftingSchema_Material_STRING { + material.StringVal = nMaterial.Value + } else if nMaterial.Digest != "" { + parts := strings.Split(nMaterial.Digest, ":") + material.SLSA = &SLSACommonProvenanceMaterial{ + &slsacommon.ProvenanceMaterial{ + URI: nMaterial.Value, + Digest: map[string]string{ + parts[0]: parts[1], + }, + }, + } + } + + res = append(res, &ProvenanceMaterial{ + Material: material, + Name: mdefName, + Type: artifactType.String(), + }) + } + + return res +} + +// Extract the Chainloop attestation predicate from an encoded DSSE envelope +func ExtractPredicate(envelope *dsse.Envelope) (*ProvenancePredicateVersions, error) { + decodedPayload, err := envelope.DecodeB64Payload() + if err != nil { + return nil, err + } + + // 1 - Extract the in-toto statement + statement := &in_toto.Statement{} + if err := json.Unmarshal(decodedPayload, statement); err != nil { + return nil, fmt.Errorf("un-marshaling predicate: %w", err) + } + + // 2 - Extract the Chainloop predicate from the in-toto statement + switch statement.PredicateType { + case PredicateTypeV01: + var predicate *ProvenancePredicateV01 + if err = extractPredicate(statement, &predicate); err != nil { + return nil, fmt.Errorf("extracting predicate: %w", err) + } + + return &ProvenancePredicateVersions{V01: predicate}, nil + default: + return nil, fmt.Errorf("unsupported predicate type: %s", statement.PredicateType) + } +} diff --git a/internal/attestation/renderer/chainloop_test.go b/internal/attestation/renderer/chainloop/v01_test.go similarity index 83% rename from internal/attestation/renderer/chainloop_test.go rename to internal/attestation/renderer/chainloop/v01_test.go index bf0f7c2cd..da6978eec 100644 --- a/internal/attestation/renderer/chainloop_test.go +++ b/internal/attestation/renderer/chainloop/v01_test.go @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package renderer +package chainloop import ( "encoding/json" @@ -29,7 +29,7 @@ import ( "google.golang.org/protobuf/encoding/protojson" ) -func TestRender(t *testing.T) { +func TestRenderV01(t *testing.T) { testCases := []struct { name string sourcePath string @@ -60,24 +60,25 @@ func TestRender(t *testing.T) { err = protojson.Unmarshal(stateRaw, state) require.NoError(t, err) - renderer, err := NewAttestationRenderer(state, "", "dev", "sha256:59e14f1a9de709cdd0e91c36b33e54fcca95f7dba1dc7169a7f81986e02108e5") - require.NoError(t, err) + renderer := NewChainloopRendererV01(state.Attestation, "dev", "sha256:59e14f1a9de709cdd0e91c36b33e54fcca95f7dba1dc7169a7f81986e02108e5") // Compare header - gotHeader, err := renderer.renderer.Header() + gotHeader, err := renderer.Header() assert.NoError(t, err) assert.Equal(t, want.Type, gotHeader.Type) assert.Equal(t, want.Subject, gotHeader.Subject) assert.Equal(t, want.PredicateType, gotHeader.PredicateType) // Compare predicate - gotPredicateI, err := renderer.renderer.Predicate() + gotPredicateI, err := renderer.Predicate() assert.NoError(t, err) - gotPredicate := gotPredicateI.(ChainloopProvenancePredicateV1) - wantPredicate, err := extractPredicateV1(want) - wantPredicate.Metadata.FinishedAt = gotPredicate.Metadata.FinishedAt + gotPredicate := gotPredicateI.(ProvenancePredicateV01) + + wantPredicate := ProvenancePredicateV01{} + err = extractPredicate(want, &wantPredicate) assert.NoError(t, err) - assert.EqualValues(t, wantPredicate, &gotPredicate) + wantPredicate.Metadata.FinishedAt = gotPredicate.Metadata.FinishedAt + assert.EqualValues(t, wantPredicate, gotPredicate) }) } } @@ -107,7 +108,7 @@ func TestExtractPredicate(t *testing.T) { envelope, err := testEnvelope(tc.envelopePath) require.NoError(t, err) - got, err := ExtractPredicate(envelope) + versions, err := ExtractPredicate(envelope) if tc.wantErr { assert.Error(t, err) return @@ -117,7 +118,7 @@ func TestExtractPredicate(t *testing.T) { require.NoError(t, err) assert.NoError(t, err) - assert.Equal(t, want, got) + assert.Equal(t, want, versions.V01) }) } } @@ -137,8 +138,8 @@ func testEnvelope(filePath string) (*dsse.Envelope, error) { return &envelope, nil } -func testPredicate(path string) (*ChainloopProvenancePredicateV1, error) { - var predicate ChainloopProvenancePredicateV1 +func testPredicate(path string) (*ProvenancePredicateV01, error) { + var predicate ProvenancePredicateV01 content, err := os.ReadFile(path) if err != nil { return nil, err diff --git a/internal/attestation/renderer/chainloop/v02.go b/internal/attestation/renderer/chainloop/v02.go new file mode 100644 index 000000000..c392546cb --- /dev/null +++ b/internal/attestation/renderer/chainloop/v02.go @@ -0,0 +1,135 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// 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. + +package chainloop + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + "strings" + + v1 "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1" + "github.com/in-toto/in-toto-golang/in_toto" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + slsa_v1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1" +) + +const PredicateTypeV02 = "chainloop.dev/attestation/v0.2" + +type ProvenancePredicateV02 struct { + *ProvenancePredicateCommon + Materials []*slsa_v1.ResourceDescriptor `json:"materials,omitempty"` +} + +type RendererV02 struct { + *RendererCommon +} + +func NewChainloopRendererV02(att *v1.Attestation, builderVersion, builderDigest string) *RendererV02 { + return &RendererV02{&RendererCommon{ + PredicateTypeV02, att, &builderInfo{builderVersion, builderDigest}}, + } +} + +func (r *RendererV02) Predicate() (interface{}, error) { + return ProvenancePredicateV02{ + ProvenancePredicateCommon: predicateCommon(r.builder, r.att), + Materials: outputSLSAMaterials(r.att, false), + }, nil +} + +func (r *RendererV02) Header() (*in_toto.StatementHeader, error) { + raw, err := json.Marshal(r.att) + if err != nil { + return nil, err + } + + // We might don't want this and just force the existence of one material with output = true + subjects := []in_toto.Subject{ + { + Name: fmt.Sprintf("chainloop.dev/workflow/%s", r.att.GetWorkflow().Name), + Digest: map[string]string{ + "sha256": fmt.Sprintf("%x", sha256.Sum256(raw)), + }, + }, + } + + for _, m := range outputSLSAMaterials(r.att, true) { + if m.Digest != nil { + subjects = append(subjects, in_toto.Subject{ + Name: m.Name, + Digest: m.Digest, + }) + } + } + + return &in_toto.StatementHeader{ + Type: in_toto.StatementInTotoV01, + PredicateType: r.predicateType, + Subject: subjects, + }, nil +} + +const AnnotationMaterialType = "chainloop.material.type" +const AnnotationMaterialName = "chainloop.material.name" + +func outputSLSAMaterials(att *v1.Attestation, onlyOutput bool) []*slsa_v1.ResourceDescriptor { + // Sort material keys to stabilize output + keys := make([]string, 0, len(att.GetMaterials())) + for k := range att.GetMaterials() { + keys = append(keys, k) + } + + sort.Strings(keys) + + res := []*slsa_v1.ResourceDescriptor{} + materials := att.GetMaterials() + for _, mdefName := range keys { + mdef := materials[mdefName] + + artifactType := mdef.MaterialType + nMaterial := mdef.NormalizedOutput() + + // Skip if we are expecting to show only the materials marked as output + if onlyOutput && !nMaterial.IsOutput { + continue + } + + material := &slsa_v1.ResourceDescriptor{} + if artifactType == schemaapi.CraftingSchema_Material_STRING { + material.Content = []byte(nMaterial.Value) + } + + if digest := nMaterial.Digest; digest != "" { + parts := strings.Split(digest, ":") + material.Digest = map[string]string{ + parts[0]: parts[1], + } + material.Name = nMaterial.Value + } + + material.Annotations = map[string]interface{}{ + AnnotationMaterialType: artifactType.String(), + AnnotationMaterialName: mdefName, + } + + res = append(res, material) + } + + return res +} diff --git a/internal/attestation/renderer/chainloop/v02_test.go b/internal/attestation/renderer/chainloop/v02_test.go new file mode 100644 index 000000000..08f3e7d85 --- /dev/null +++ b/internal/attestation/renderer/chainloop/v02_test.go @@ -0,0 +1,82 @@ +// +// Copyright 2023 The Chainloop Authors. +// +// 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. + +package chainloop + +import ( + "encoding/json" + "os" + "testing" + + api "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1" + "github.com/in-toto/in-toto-golang/in_toto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" +) + +func TestRenderV02(t *testing.T) { + testCases := []struct { + name string + sourcePath string + outputPath string + }{ + { + name: "render v0.2", + sourcePath: "testdata/attestation.source.json", + outputPath: "testdata/attestation.output.v0.2.json", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Load expected resulting output + wantRaw, err := os.ReadFile(tc.outputPath) + require.NoError(t, err) + + var want *in_toto.Statement + err = json.Unmarshal(wantRaw, &want) + require.NoError(t, err) + + // Initialize renderer + state := &api.CraftingState{} + stateRaw, err := os.ReadFile(tc.sourcePath) + require.NoError(t, err) + + err = protojson.Unmarshal(stateRaw, state) + require.NoError(t, err) + + renderer := NewChainloopRendererV02(state.Attestation, "dev", "sha256:59e14f1a9de709cdd0e91c36b33e54fcca95f7dba1dc7169a7f81986e02108e5") + + // Compare header + gotHeader, err := renderer.Header() + assert.NoError(t, err) + assert.Equal(t, want.Type, gotHeader.Type) + assert.Equal(t, want.Subject, gotHeader.Subject) + assert.Equal(t, want.PredicateType, gotHeader.PredicateType) + + // Compare predicate + gotPredicateI, err := renderer.Predicate() + assert.NoError(t, err) + gotPredicate := gotPredicateI.(ProvenancePredicateV02) + + wantPredicate := ProvenancePredicateV02{} + err = extractPredicate(want, &wantPredicate) + assert.NoError(t, err) + wantPredicate.Metadata.FinishedAt = gotPredicate.Metadata.FinishedAt + assert.EqualValues(t, wantPredicate, gotPredicate) + }) + } +} diff --git a/internal/attestation/renderer/renderer.go b/internal/attestation/renderer/renderer.go index 4507cb106..3a54709ae 100644 --- a/internal/attestation/renderer/renderer.go +++ b/internal/attestation/renderer/renderer.go @@ -26,6 +26,7 @@ import ( "syscall" v1 "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1" + "github.com/chainloop-dev/chainloop/internal/attestation/renderer/chainloop" "github.com/in-toto/in-toto-golang/in_toto" "github.com/rs/zerolog" "github.com/secure-systems-lab/go-securesystemslib/dsse" @@ -63,7 +64,7 @@ func NewAttestationRenderer(state *v1.CraftingState, keyPath, builderVersion, bu logger: zerolog.Nop(), signingKeyPath: keyPath, att: state.GetAttestation(), - renderer: newChainloopRenderer(state.GetAttestation(), builderVersion, builderDigest), + renderer: chainloop.NewChainloopRendererV01(state.GetAttestation(), builderVersion, builderDigest), } for _, opt := range opts {