diff --git a/app/cli/cmd/attestation_add.go b/app/cli/cmd/attestation_add.go index f08b4f24a..5b223376e 100644 --- a/app/cli/cmd/attestation_add.go +++ b/app/cli/cmd/attestation_add.go @@ -29,6 +29,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/cmd/output" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" "github.com/chainloop-dev/chainloop/pkg/resourceloader" ) @@ -194,6 +195,9 @@ func displayMaterialInfo(status *action.AttestationStatusMaterial, policyEvaluat if len(status.Material.Annotations) > 0 { mt.AppendRow(table.Row{"Annotations", "------"}) for _, a := range status.Material.Annotations { + if materials.IsLegacyAnnotation(a.Name) { + continue + } value := a.Value if value == "" { value = NotSet diff --git a/app/cli/cmd/attestation_status.go b/app/cli/cmd/attestation_status.go index af57f3904..9dc88df94 100644 --- a/app/cli/cmd/attestation_status.go +++ b/app/cli/cmd/attestation_status.go @@ -30,6 +30,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/cmd/output" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" ) @@ -114,6 +115,9 @@ func attestationStatusTableOutput(status *action.AttestationStatusResult, w io.W if len(status.Annotations) > 0 { gt.AppendRow(table.Row{"Annotations", "------"}) for _, a := range status.Annotations { + if materials.IsLegacyAnnotation(a.Name) { + continue + } value := a.Value if value == "" { value = NotSet @@ -220,6 +224,9 @@ func materialsTable(status *action.AttestationStatusResult, w io.Writer, full bo if len(m.Annotations) > 0 { mt.AppendRow(table.Row{"Annotations", "------"}) for _, a := range m.Annotations { + if materials.IsLegacyAnnotation(a.Name) { + continue + } value := a.Value if value == "" { value = NotSet diff --git a/pkg/attestation/crafter/materials/cyclonedxjson.go b/pkg/attestation/crafter/materials/cyclonedxjson.go index 28d928565..658d516cb 100644 --- a/pkg/attestation/crafter/materials/cyclonedxjson.go +++ b/pkg/attestation/crafter/materials/cyclonedxjson.go @@ -166,19 +166,37 @@ func (i *CyclonedxJSONCrafter) extractMetadata(m *api.Attestation_Material, meta i.logger.Debug().Err(err).Msg("error extracting main component from sbom, skipping...") } - if len(meta.Tools) > 0 { - m.Annotations[AnnotationToolNameKey] = meta.Tools[0].Name - m.Annotations[AnnotationToolVersionKey] = meta.Tools[0].Version + // Extract all tools and set annotations + var tools []Tool + for _, tool := range meta.Tools { + tools = append(tools, Tool{Name: tool.Name, Version: tool.Version}) } + SetToolsAnnotation(m, tools) + + // Maintain backward compatibility - keep legacy keys for the first tool + if len(tools) > 0 { + m.Annotations[AnnotationToolNameKey] = tools[0].Name + m.Annotations[AnnotationToolVersionKey] = tools[0].Version + } + case *cyclonedxMetadataV15: if err := i.extractMainComponent(m, &meta.Component); err != nil { i.logger.Debug().Err(err).Msg("error extracting main component from sbom, skipping...") } - if len(meta.Tools.Components) > 0 { - m.Annotations[AnnotationToolNameKey] = meta.Tools.Components[0].Name - m.Annotations[AnnotationToolVersionKey] = meta.Tools.Components[0].Version + // Extract all tools and set annotations + var tools []Tool + for _, tool := range meta.Tools.Components { + tools = append(tools, Tool{Name: tool.Name, Version: tool.Version}) } + SetToolsAnnotation(m, tools) + + // Maintain backward compatibility - keep legacy keys for the first tool + if len(tools) > 0 { + m.Annotations[AnnotationToolNameKey] = tools[0].Name + m.Annotations[AnnotationToolVersionKey] = tools[0].Version + } + default: i.logger.Debug().Msg("unknown metadata version") } diff --git a/pkg/attestation/crafter/materials/cyclonedxjson_test.go b/pkg/attestation/crafter/materials/cyclonedxjson_test.go index 1cd0ef6b5..401fb8a29 100644 --- a/pkg/attestation/crafter/materials/cyclonedxjson_test.go +++ b/pkg/attestation/crafter/materials/cyclonedxjson_test.go @@ -156,6 +156,20 @@ func TestCyclonedxJSONCraft(t *testing.T) { "chainloop.material.sbom.vulnerabilities_report": "true", }, }, + { + name: "1.5 version with multiple tools", + filePath: "./testdata/sbom.cyclonedx-1.5-multiple-tools.json", + wantDigest: "sha256:56f82c99fb4740f952296705ceb2ee0c5c3c6a3309b35373d542d58878d65cd3", + wantFilename: "sbom.cyclonedx-1.5-multiple-tools.json", + wantMainComponent: "test-app", + wantMainComponentKind: "application", + wantMainComponentVersion: "1.0.0", + annotations: map[string]string{ + "chainloop.material.tool.name": "Hub", + "chainloop.material.tool.version": "2025.4.2", + "chainloop.material.tools": `["Hub@2025.4.2","cyclonedx-core-java@5.0.5"]`, + }, + }, } schema := &contractAPI.CraftingSchema_Material{ diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index 3d0941683..98349377f 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -17,6 +17,7 @@ package materials import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -36,6 +37,40 @@ import ( const AnnotationToolNameKey = "chainloop.material.tool.name" const AnnotationToolVersionKey = "chainloop.material.tool.version" +const AnnotationToolsKey = "chainloop.material.tools" + +// IsLegacyAnnotation returns true if the annotation key is a legacy annotation +func IsLegacyAnnotation(key string) bool { + return key == AnnotationToolNameKey || key == AnnotationToolVersionKey +} + +// Tool represents a tool with name and version +type Tool struct { + Name string + Version string +} + +// SetToolsAnnotations sets the tools annotation as a JSON array in "name@version" format +func SetToolsAnnotation(m *api.Attestation_Material, tools []Tool) { + if len(tools) == 0 { + return + } + + // Build array of "name@version" strings + toolStrings := make([]string, 0, len(tools)) + for _, tool := range tools { + toolStr := tool.Name + if tool.Version != "" { + toolStr = fmt.Sprintf("%s@%s", tool.Name, tool.Version) + } + toolStrings = append(toolStrings, toolStr) + } + + // Marshal to JSON array + if toolsJSON, err := json.Marshal(toolStrings); err == nil { + m.Annotations[AnnotationToolsKey] = string(toolsJSON) + } +} var ( // ErrInvalidMaterialType is returned when the provided material type diff --git a/pkg/attestation/crafter/materials/spdxjson.go b/pkg/attestation/crafter/materials/spdxjson.go index 1809e134b..c359b4ce5 100644 --- a/pkg/attestation/crafter/materials/spdxjson.go +++ b/pkg/attestation/crafter/materials/spdxjson.go @@ -71,19 +71,27 @@ func (i *SPDXJSONCrafter) Craft(ctx context.Context, filePath string) (*api.Atte } func (i *SPDXJSONCrafter) injectAnnotations(m *api.Attestation_Material, doc *spdx.Document) { + m.Annotations = make(map[string]string) + + // Extract all tools from the creators array + var tools []Tool for _, c := range doc.CreationInfo.Creators { if c.CreatorType == "Tool" { - m.Annotations = make(map[string]string) - m.Annotations[AnnotationToolNameKey] = c.Creator - // try to extract the tool name and version // e.g. "myTool-1.0.0" - parts := strings.SplitN(c.Creator, "-", 2) - if len(parts) == 2 { - m.Annotations[AnnotationToolNameKey] = parts[0] - m.Annotations[AnnotationToolVersionKey] = parts[1] + name, version := c.Creator, "" + if parts := strings.SplitN(c.Creator, "-", 2); len(parts) == 2 { + name, version = parts[0], parts[1] } - break + tools = append(tools, Tool{Name: name, Version: version}) } } + + SetToolsAnnotation(m, tools) + + // Maintain backward compatibility - keep legacy keys for the first tool + if len(tools) > 0 { + m.Annotations[AnnotationToolNameKey] = tools[0].Name + m.Annotations[AnnotationToolVersionKey] = tools[0].Version + } } diff --git a/pkg/attestation/crafter/materials/spdxjson_test.go b/pkg/attestation/crafter/materials/spdxjson_test.go index 7882d7fa7..66c55ebae 100644 --- a/pkg/attestation/crafter/materials/spdxjson_test.go +++ b/pkg/attestation/crafter/materials/spdxjson_test.go @@ -66,9 +66,12 @@ func TestNewSPDXJSONCrafter(t *testing.T) { func TestSPDXJSONCraft(t *testing.T) { testCases := []struct { - name string - filePath string - wantErr string + name string + filePath string + wantErr string + wantDigest string + wantFilename string + annotations map[string]string }{ { name: "invalid sbom format", @@ -86,8 +89,26 @@ func TestSPDXJSONCraft(t *testing.T) { wantErr: "unexpected material type", }, { - name: "valid artifact type", - filePath: "./testdata/sbom-spdx.json", + name: "valid artifact type", + filePath: "./testdata/sbom-spdx.json", + wantDigest: "sha256:fe2636fb6c698a29a315278b762b2000efd5959afe776ee4f79f1ed523365a33", + wantFilename: "sbom-spdx.json", + annotations: map[string]string{ + "chainloop.material.tool.name": "syft", + "chainloop.material.tool.version": "0.73.0", + "chainloop.material.tools": `["syft@0.73.0"]`, + }, + }, + { + name: "multiple tools", + filePath: "./testdata/sbom-spdx-multiple-tools.json", + wantDigest: "sha256:c1a61566c7c0224ac02ad9cd21d90234e5a71de26971e33df2205c1a2eb319fc", + wantFilename: "sbom-spdx-multiple-tools.json", + annotations: map[string]string{ + "chainloop.material.tool.name": "spdxgen", + "chainloop.material.tool.version": "1.0.0", + "chainloop.material.tools": `["spdxgen@1.0.0","scanner@2.1.5"]`, + }, }, } @@ -123,10 +144,17 @@ func TestSPDXJSONCraft(t *testing.T) { assert.Equal(contractAPI.CraftingSchema_Material_SBOM_SPDX_JSON.String(), got.MaterialType.String()) assert.True(got.UploadedToCas) - // // The result includes the digest reference + // The result includes the digest reference assert.Equal(got.GetArtifact(), &attestationApi.Attestation_Material_Artifact{ - Id: "test", Digest: "sha256:fe2636fb6c698a29a315278b762b2000efd5959afe776ee4f79f1ed523365a33", Name: "sbom-spdx.json", + Id: "test", Digest: tc.wantDigest, Name: tc.wantFilename, }) + + // Validate annotations if specified + if tc.annotations != nil { + for k, v := range tc.annotations { + assert.Equal(v, got.Annotations[k]) + } + } }) } } diff --git a/pkg/attestation/crafter/materials/testdata/sbom-spdx-multiple-tools.json b/pkg/attestation/crafter/materials/testdata/sbom-spdx-multiple-tools.json new file mode 100644 index 000000000..8f9e2a44a --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/sbom-spdx-multiple-tools.json @@ -0,0 +1,27 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "test-multiple-tools", + "documentNamespace": "https://example.com/test/multiple-tools", + "creationInfo": { + "licenseListVersion": "3.20", + "creators": [ + "Organization: Example Corp", + "Tool: spdxgen-1.0.0", + "Tool: scanner-2.1.5" + ], + "created": "2024-01-01T10:00:00Z" + }, + "packages": [ + { + "name": "example-package", + "SPDXID": "SPDXRef-Package-example", + "versionInfo": "1.0.0", + "downloadLocation": "NOASSERTION", + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "copyrightText": "NOASSERTION" + } + ] +} diff --git a/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-1.5-multiple-tools.json b/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-1.5-multiple-tools.json new file mode 100644 index 000000000..06bd1010f --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-1.5-multiple-tools.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2025-09-28T07:00:46Z", + "tools": { + "components": [ + { + "type": "application", + "author": "Black Duck", + "name": "Hub", + "version": "2025.4.2" + }, + { + "type": "library", + "author": "CycloneDX", + "name": "cyclonedx-core-java", + "version": "5.0.5" + } + ] + }, + "component": { + "bom-ref": "test-component", + "type": "application", + "name": "test-app", + "version": "1.0.0" + } + }, + "components": [ + { + "bom-ref": "pkg:golang/example.com/test@v1.0.0", + "type": "library", + "name": "example.com/test", + "version": "v1.0.0", + "purl": "pkg:golang/example.com/test@v1.0.0" + } + ] +}