diff --git a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts index ba9378296..f508c5abc 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -119,6 +119,7 @@ export enum CraftingSchema_Material_MaterialType { * https://github.com/microsoft/sarif-tutorials/blob/main/docs/1-Introduction.md */ SARIF = 9, + HELM_CHART = 10, UNRECOGNIZED = -1, } @@ -154,6 +155,9 @@ export function craftingSchema_Material_MaterialTypeFromJSON(object: any): Craft case 9: case "SARIF": return CraftingSchema_Material_MaterialType.SARIF; + case 10: + case "HELM_CHART": + return CraftingSchema_Material_MaterialType.HELM_CHART; case -1: case "UNRECOGNIZED": default: @@ -183,6 +187,8 @@ export function craftingSchema_Material_MaterialTypeToJSON(object: CraftingSchem return "CSAF_VEX"; case CraftingSchema_Material_MaterialType.SARIF: return "SARIF"; + case CraftingSchema_Material_MaterialType.HELM_CHART: + return "HELM_CHART"; case CraftingSchema_Material_MaterialType.UNRECOGNIZED: default: return "UNRECOGNIZED"; diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index 042c12423..d7a705678 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -113,22 +113,24 @@ const ( CraftingSchema_Material_CSAF_VEX CraftingSchema_Material_MaterialType = 8 // Static analysis output format // https://github.com/microsoft/sarif-tutorials/blob/main/docs/1-Introduction.md - CraftingSchema_Material_SARIF CraftingSchema_Material_MaterialType = 9 + CraftingSchema_Material_SARIF CraftingSchema_Material_MaterialType = 9 + CraftingSchema_Material_HELM_CHART CraftingSchema_Material_MaterialType = 10 ) // Enum value maps for CraftingSchema_Material_MaterialType. var ( CraftingSchema_Material_MaterialType_name = map[int32]string{ - 0: "MATERIAL_TYPE_UNSPECIFIED", - 1: "STRING", - 2: "CONTAINER_IMAGE", - 3: "ARTIFACT", - 4: "SBOM_CYCLONEDX_JSON", - 5: "SBOM_SPDX_JSON", - 6: "JUNIT_XML", - 7: "OPENVEX", - 8: "CSAF_VEX", - 9: "SARIF", + 0: "MATERIAL_TYPE_UNSPECIFIED", + 1: "STRING", + 2: "CONTAINER_IMAGE", + 3: "ARTIFACT", + 4: "SBOM_CYCLONEDX_JSON", + 5: "SBOM_SPDX_JSON", + 6: "JUNIT_XML", + 7: "OPENVEX", + 8: "CSAF_VEX", + 9: "SARIF", + 10: "HELM_CHART", } CraftingSchema_Material_MaterialType_value = map[string]int32{ "MATERIAL_TYPE_UNSPECIFIED": 0, @@ -141,6 +143,7 @@ var ( "OPENVEX": 7, "CSAF_VEX": 8, "SARIF": 9, + "HELM_CHART": 10, } ) @@ -449,7 +452,7 @@ var file_workflowcontract_v1_crafting_schema_proto_rawDesc = []byte{ 0x63, 0x68, 0x65, 0x6d, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x13, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x2e, 0x76, 0x31, 0x1a, 0x1b, 0x62, 0x75, 0x66, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, - 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x87, 0x08, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x97, 0x08, 0x0a, 0x0e, 0x43, 0x72, 0x61, 0x66, 0x74, 0x69, 0x6e, 0x67, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x12, 0x30, 0x0a, 0x0e, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x61, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x09, 0xba, 0x48, 0x06, 0x72, 0x04, 0x0a, @@ -486,7 +489,7 @@ var file_workflowcontract_v1_crafting_schema_proto_rawDesc = []byte{ 0x53, 0x5f, 0x4a, 0x4f, 0x42, 0x10, 0x04, 0x12, 0x12, 0x0a, 0x0e, 0x43, 0x49, 0x52, 0x43, 0x4c, 0x45, 0x43, 0x49, 0x5f, 0x42, 0x55, 0x49, 0x4c, 0x44, 0x10, 0x05, 0x12, 0x13, 0x0a, 0x0f, 0x44, 0x41, 0x47, 0x47, 0x45, 0x52, 0x5f, 0x50, 0x49, 0x50, 0x45, 0x4c, 0x49, 0x4e, 0x45, 0x10, 0x06, - 0x1a, 0xc4, 0x03, 0x0a, 0x08, 0x4d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x5a, 0x0a, + 0x1a, 0xd4, 0x03, 0x0a, 0x08, 0x4d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x5a, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x39, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x61, 0x66, 0x74, 0x69, 0x6e, 0x67, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x61, @@ -502,7 +505,7 @@ var file_workflowcontract_v1_crafting_schema_proto_rawDesc = []byte{ 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x66, 0x6c, 0x6f, 0x77, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0b, 0x61, 0x6e, 0x6e, 0x6f, 0x74, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xbe, 0x01, 0x0a, 0x0c, 0x4d, 0x61, 0x74, 0x65, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xce, 0x01, 0x0a, 0x0c, 0x4d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x19, 0x4d, 0x41, 0x54, 0x45, 0x52, 0x49, 0x41, 0x4c, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x54, 0x52, 0x49, 0x4e, 0x47, @@ -514,7 +517,8 @@ var file_workflowcontract_v1_crafting_schema_proto_rawDesc = []byte{ 0x10, 0x05, 0x12, 0x0d, 0x0a, 0x09, 0x4a, 0x55, 0x4e, 0x49, 0x54, 0x5f, 0x58, 0x4d, 0x4c, 0x10, 0x06, 0x12, 0x0b, 0x0a, 0x07, 0x4f, 0x50, 0x45, 0x4e, 0x56, 0x45, 0x58, 0x10, 0x07, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x53, 0x41, 0x46, 0x5f, 0x56, 0x45, 0x58, 0x10, 0x08, 0x12, 0x09, 0x0a, 0x05, - 0x53, 0x41, 0x52, 0x49, 0x46, 0x10, 0x09, 0x22, 0x46, 0x0a, 0x0a, 0x41, 0x6e, 0x6e, 0x6f, 0x74, + 0x53, 0x41, 0x52, 0x49, 0x46, 0x10, 0x09, 0x12, 0x0e, 0x0a, 0x0a, 0x48, 0x45, 0x4c, 0x4d, 0x5f, + 0x43, 0x48, 0x41, 0x52, 0x54, 0x10, 0x0a, 0x22, 0x46, 0x0a, 0x0a, 0x41, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0e, 0xba, 0x48, 0x0b, 0x72, 0x09, 0x32, 0x07, 0x5e, 0x5b, 0x5c, 0x77, 0x5d, 0x2b, 0x24, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index c66df841c..04aec6628 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -83,6 +83,7 @@ message CraftingSchema { // Static analysis output format // https://github.com/microsoft/sarif-tutorials/blob/main/docs/1-Introduction.md SARIF = 9; + HELM_CHART = 10; } } } diff --git a/go.mod b/go.mod index f0f533397..1f1a6670b 100644 --- a/go.mod +++ b/go.mod @@ -310,7 +310,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.28.3 // indirect k8s.io/apimachinery v0.28.3 diff --git a/internal/attestation/crafter/materials/helmchart.go b/internal/attestation/crafter/materials/helmchart.go new file mode 100644 index 000000000..49e7d1464 --- /dev/null +++ b/internal/attestation/crafter/materials/helmchart.go @@ -0,0 +1,132 @@ +// +// Copyright 2024 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 materials + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "strings" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + api "github.com/chainloop-dev/chainloop/internal/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/internal/casclient" + "github.com/rs/zerolog" + "gopkg.in/yaml.v2" +) + +const ( + // chartFileName is the name of the Chart.yaml file in the helm chart + chartFileName = "Chart.yaml" + // chartValuesYamlFileName is the name of the values.yaml file in the helm chart + chartValuesYamlFileName = "values.yaml" +) + +type HelmChartCrafter struct { + backend *casclient.CASBackend + *crafterCommon +} + +func NewHelmChartCrafter(materialSchema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, + l *zerolog.Logger) (*HelmChartCrafter, error) { + if materialSchema.Type != schemaapi.CraftingSchema_Material_HELM_CHART { + return nil, fmt.Errorf("material type is not HELM_CHART format") + } + + return &HelmChartCrafter{ + backend: backend, + crafterCommon: &crafterCommon{logger: l, input: materialSchema}, + }, nil +} + +func (c *HelmChartCrafter) Craft(ctx context.Context, filepath string) (*api.Attestation_Material, error) { + // Open the helm chart tar file + f, err := os.Open(filepath) + if err != nil { + return nil, fmt.Errorf("can't open the file: %w", err) + } + defer f.Close() + + // Decompress the file if possible + uncompressedStream, err := gzip.NewReader(f) + if err != nil { + return nil, fmt.Errorf("can't uncompress file, unexpected material type: %w", err) + } + + // Create a tar reader + tarReader := tar.NewReader(uncompressedStream) + + // Flags to track whether required files are found + var chartFileValid, chartValuesValid bool + + // Iterate through the files in the tar archive + for { + header, err := tarReader.Next() + if err == io.EOF { + // Reached the end of tar archive + break + } + if err != nil { + return nil, fmt.Errorf("error reading tar file: %w", err) + } + + // Check if the file is a regular file + if header.Typeflag != tar.TypeReg { + continue // Skip if it's not a regular file + } + + // Validate Chart.yaml and values.yaml files. The files will have prepended the path of the directory + // it was compressed from. So, we can check if the file name contains the required file names + // Ex: helm-chart/Chart.yaml, helm-chart/values.yaml + if strings.Contains(header.Name, chartFileName) { + if err := c.validateYamlFile(tarReader); err != nil { + return nil, fmt.Errorf("invalid Chart.yaml file: %w", err) + } + chartFileValid = true + } else if strings.Contains(header.Name, chartValuesYamlFileName) { + if err := c.validateYamlFile(tarReader); err != nil { + return nil, fmt.Errorf("invalid values.yaml file: %w", err) + } + chartValuesValid = true + } + + // Stop iterating if both files are found + if chartValuesValid && chartFileValid { + break + } + } + + // If the chart.yaml and values.yaml files are not found, return an error + if !chartFileValid || !chartValuesValid { + return nil, fmt.Errorf("missing required files in the helm chart: Chart.yaml and values.yaml") + } + + // Upload and craft the chart + return uploadAndCraft(ctx, c.input, c.backend, filepath, c.logger) +} + +// validateYamlFile validates the YAML file just by trying to unmarshal it +func (c *HelmChartCrafter) validateYamlFile(r io.Reader) error { + v := make(map[string]interface{}) + if err := yaml.NewDecoder(r).Decode(v); err != nil { + return fmt.Errorf("failed to unmarshal YAML file: %w", err) + } + + return nil +} diff --git a/internal/attestation/crafter/materials/helmchart_test.go b/internal/attestation/crafter/materials/helmchart_test.go new file mode 100644 index 000000000..224a0ebfa --- /dev/null +++ b/internal/attestation/crafter/materials/helmchart_test.go @@ -0,0 +1,142 @@ +// +// Copyright 2024 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 materials_test + +import ( + "context" + "testing" + + contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + attestationApi "github.com/chainloop-dev/chainloop/internal/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/internal/attestation/crafter/materials" + "github.com/chainloop-dev/chainloop/internal/casclient" + mUploader "github.com/chainloop-dev/chainloop/internal/casclient/mocks" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewHelmChartCrafter(t *testing.T) { + testCases := []struct { + name string + input *contractAPI.CraftingSchema_Material + wantErr bool + }{ + { + name: "happy path", + input: &contractAPI.CraftingSchema_Material{ + Type: contractAPI.CraftingSchema_Material_HELM_CHART, + }, + }, + { + name: "wrong type", + input: &contractAPI.CraftingSchema_Material{ + Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, + }, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := materials.NewHelmChartCrafter(tc.input, nil, nil) + if tc.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} + +func TestHelmChartCraft(t *testing.T) { + testCases := []struct { + name string + filePath string + wantErr string + wantFilename string + wantDigest string + }{ + { + name: "invalid path", + filePath: "./testdata/non-existing.json", + wantErr: "no such file or directory", + }, + { + name: "empty tarball", + filePath: "./testdata/missing-empty.tgz", + wantErr: "missing required files in the helm chart: Chart.yaml and values.yaml", + }, + { + name: "missing Chart.yaml file", + filePath: "./testdata/missing-chartyaml.tgz", + wantErr: "missing required files in the helm chart: Chart.yaml and values.yaml", + }, + { + name: "missing values.yaml file", + filePath: "./testdata/missing-valuesyaml.tgz", + wantErr: "missing required files in the helm chart: Chart.yaml and values.yaml", + }, + { + name: "invalid artifact type", + filePath: "./testdata/simple.txt", + wantErr: "unexpected material type", + }, + { + name: "valid artifact type", + filePath: "./testdata/valid-chart.tgz", + wantDigest: "sha256:08a46a850789938ede61d6a53552f48cb8ba74c4e17dcf30c9c50e5783ca6a13", + wantFilename: "valid-chart.tgz", + }, + } + + assert := assert.New(t) + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_HELM_CHART, + } + l := zerolog.Nop() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Mock uploader + uploader := mUploader.NewUploader(t) + if tc.wantErr == "" { + uploader.On("UploadFile", context.TODO(), tc.filePath). + Return(&casclient.UpDownStatus{}, nil) + } + + backend := &casclient.CASBackend{Uploader: uploader} + crafter, err := materials.NewHelmChartCrafter(schema, backend, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), tc.filePath) + if tc.wantErr != "" { + assert.ErrorContains(err, tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(contractAPI.CraftingSchema_Material_HELM_CHART.String(), got.MaterialType.String()) + assert.True(got.UploadedToCas) + + // The result includes the digest reference + assert.Equal(&attestationApi.Attestation_Material_Artifact{ + Id: "test", Digest: tc.wantDigest, Name: tc.wantFilename, + }, got.GetArtifact()) + }) + } +} diff --git a/internal/attestation/crafter/materials/materials.go b/internal/attestation/crafter/materials/materials.go index 78c9a8645..c8bea87b2 100644 --- a/internal/attestation/crafter/materials/materials.go +++ b/internal/attestation/crafter/materials/materials.go @@ -169,6 +169,8 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia crafter, err = NewCSAFVEXCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_SARIF: crafter, err = NewSARIFCrafter(materialSchema, casBackend, logger) + case schemaapi.CraftingSchema_Material_HELM_CHART: + crafter, err = NewHelmChartCrafter(materialSchema, casBackend, logger) default: return nil, fmt.Errorf("material of type %q not supported yet", materialSchema.Type) } diff --git a/internal/attestation/crafter/materials/testdata/missing-chartyaml.tgz b/internal/attestation/crafter/materials/testdata/missing-chartyaml.tgz new file mode 100644 index 000000000..795f6a007 Binary files /dev/null and b/internal/attestation/crafter/materials/testdata/missing-chartyaml.tgz differ diff --git a/internal/attestation/crafter/materials/testdata/missing-empty.tgz b/internal/attestation/crafter/materials/testdata/missing-empty.tgz new file mode 100644 index 000000000..ec8ba757a Binary files /dev/null and b/internal/attestation/crafter/materials/testdata/missing-empty.tgz differ diff --git a/internal/attestation/crafter/materials/testdata/missing-valuesyaml.tgz b/internal/attestation/crafter/materials/testdata/missing-valuesyaml.tgz new file mode 100644 index 000000000..01e3dacac Binary files /dev/null and b/internal/attestation/crafter/materials/testdata/missing-valuesyaml.tgz differ diff --git a/internal/attestation/crafter/materials/testdata/valid-chart.tgz b/internal/attestation/crafter/materials/testdata/valid-chart.tgz new file mode 100644 index 000000000..3fe488997 Binary files /dev/null and b/internal/attestation/crafter/materials/testdata/valid-chart.tgz differ