From c5c6ed9e7ae0d6a701751d6b04c689cd564916f2 Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 18 Mar 2026 18:47:10 +0100 Subject: [PATCH 1/2] fix(materials): guard legacy tool annotations against empty values SBOM uploads fail when a tool entry has an empty version string because the legacy annotation is set to "" and then rejected by the crafter validation. Guard all legacy annotation assignments with non-empty checks across all material crafters. Fixes #2880 Signed-off-by: Jose I. Paris --- pkg/attestation/crafter/materials/csaf.go | 6 ++-- .../crafter/materials/cyclonedxjson.go | 18 +++++++--- .../crafter/materials/cyclonedxjson_test.go | 36 ++++++++++++++++++- pkg/attestation/crafter/materials/sarif.go | 8 +++-- pkg/attestation/crafter/materials/spdxjson.go | 10 ++++-- .../crafter/materials/spdxjson_test.go | 31 ++++++++++++---- .../testdata/sbom-spdx-no-tool-version.json | 26 ++++++++++++++ ...sbom.cyclonedx-1.4-empty-tool-version.json | 31 ++++++++++++++++ ...sbom.cyclonedx-1.5-empty-tool-version.json | 35 ++++++++++++++++++ 9 files changed, 179 insertions(+), 22 deletions(-) create mode 100644 pkg/attestation/crafter/materials/testdata/sbom-spdx-no-tool-version.json create mode 100644 pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-1.4-empty-tool-version.json create mode 100644 pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-1.5-empty-tool-version.json diff --git a/pkg/attestation/crafter/materials/csaf.go b/pkg/attestation/crafter/materials/csaf.go index a19cff11f..212eca421 100644 --- a/pkg/attestation/crafter/materials/csaf.go +++ b/pkg/attestation/crafter/materials/csaf.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 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. @@ -146,10 +146,10 @@ func (i *CSAFCrafter) injectAnnotations(m *api.Attestation_Material, documentMap if tracking, ok := documentMap["tracking"].(map[string]any); ok { if generator, ok := tracking["generator"].(map[string]any); ok { if engine, ok := generator["engine"].(map[string]any); ok { - if name, ok := engine["name"].(string); ok { + if name, ok := engine["name"].(string); ok && name != "" { m.Annotations[AnnotationToolNameKey] = name } - if version, ok := engine["version"].(string); ok { + if version, ok := engine["version"].(string); ok && version != "" { m.Annotations[AnnotationToolVersionKey] = version } } diff --git a/pkg/attestation/crafter/materials/cyclonedxjson.go b/pkg/attestation/crafter/materials/cyclonedxjson.go index 102f062f1..17fbc38d7 100644 --- a/pkg/attestation/crafter/materials/cyclonedxjson.go +++ b/pkg/attestation/crafter/materials/cyclonedxjson.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 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. @@ -214,8 +214,12 @@ func (i *CyclonedxJSONCrafter) extractMetadata(m *api.Attestation_Material, meta // 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 + if tools[0].Name != "" { + m.Annotations[AnnotationToolNameKey] = tools[0].Name + } + if tools[0].Version != "" { + m.Annotations[AnnotationToolVersionKey] = tools[0].Version + } } case *cyclonedxMetadataV15: @@ -232,8 +236,12 @@ func (i *CyclonedxJSONCrafter) extractMetadata(m *api.Attestation_Material, meta // 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 + if tools[0].Name != "" { + m.Annotations[AnnotationToolNameKey] = tools[0].Name + } + if tools[0].Version != "" { + m.Annotations[AnnotationToolVersionKey] = tools[0].Version + } } default: diff --git a/pkg/attestation/crafter/materials/cyclonedxjson_test.go b/pkg/attestation/crafter/materials/cyclonedxjson_test.go index 5112407ea..63223663e 100644 --- a/pkg/attestation/crafter/materials/cyclonedxjson_test.go +++ b/pkg/attestation/crafter/materials/cyclonedxjson_test.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 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. @@ -74,6 +74,7 @@ func TestCyclonedxJSONCraft(t *testing.T) { wantMainComponentKind string wantMainComponentVersion string annotations map[string]string + absentAnnotations []string }{ { name: "invalid path", @@ -156,6 +157,34 @@ func TestCyclonedxJSONCraft(t *testing.T) { "chainloop.material.sbom.vulnerabilities_report": "true", }, }, + { + name: "1.4 version with empty tool version", + filePath: "./testdata/sbom.cyclonedx-1.4-empty-tool-version.json", + wantDigest: "sha256:f0b15f1ae6cadb9f14e0071f14888bcbae03686580f8fc8dc0c838e6e96aaf0e", + wantFilename: "sbom.cyclonedx-1.4-empty-tool-version.json", + wantMainComponent: "test-app", + wantMainComponentKind: "application", + wantMainComponentVersion: "1.0.0", + annotations: map[string]string{ + "chainloop.material.tool.name": "Hub", + "chainloop.material.tools": `["Hub"]`, + }, + absentAnnotations: []string{"chainloop.material.tool.version"}, + }, + { + name: "1.5 version with empty tool version", + filePath: "./testdata/sbom.cyclonedx-1.5-empty-tool-version.json", + wantDigest: "sha256:4091c3b42ec5d368365f5b6fba80dd30cc3ddf84f795096e738b02f8e2716ce1", + wantFilename: "sbom.cyclonedx-1.5-empty-tool-version.json", + wantMainComponent: "test-app", + wantMainComponentKind: "application", + wantMainComponentVersion: "1.0.0", + annotations: map[string]string{ + "chainloop.material.tool.name": "Hub", + "chainloop.material.tools": `["Hub"]`, + }, + absentAnnotations: []string{"chainloop.material.tool.version"}, + }, { name: "1.5 version with multiple tools", filePath: "./testdata/sbom.cyclonedx-1.5-multiple-tools.json", @@ -221,6 +250,11 @@ func TestCyclonedxJSONCraft(t *testing.T) { ast.Equal(v, got.Annotations[k]) } } + + for _, k := range tc.absentAnnotations { + _, exists := got.Annotations[k] + ast.False(exists, "annotation %q should not be present", k) + } }) } } diff --git a/pkg/attestation/crafter/materials/sarif.go b/pkg/attestation/crafter/materials/sarif.go index 0c64cfe15..cb6918b10 100644 --- a/pkg/attestation/crafter/materials/sarif.go +++ b/pkg/attestation/crafter/materials/sarif.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 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. @@ -69,8 +69,10 @@ func (i *SARIFCrafter) injectAnnotations(m *api.Attestation_Material, doc *sarif if len(doc.Runs) > 0 { // assuming vendor from first run. m.Annotations = make(map[string]string) - m.Annotations[AnnotationToolNameKey] = doc.Runs[0].Tool.Driver.Name - if doc.Runs[0].Tool.Driver.Version != nil { + if doc.Runs[0].Tool.Driver.Name != "" { + m.Annotations[AnnotationToolNameKey] = doc.Runs[0].Tool.Driver.Name + } + if doc.Runs[0].Tool.Driver.Version != nil && *doc.Runs[0].Tool.Driver.Version != "" { m.Annotations[AnnotationToolVersionKey] = *doc.Runs[0].Tool.Driver.Version } } diff --git a/pkg/attestation/crafter/materials/spdxjson.go b/pkg/attestation/crafter/materials/spdxjson.go index c359b4ce5..885ad2cdd 100644 --- a/pkg/attestation/crafter/materials/spdxjson.go +++ b/pkg/attestation/crafter/materials/spdxjson.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 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. @@ -91,7 +91,11 @@ func (i *SPDXJSONCrafter) injectAnnotations(m *api.Attestation_Material, doc *sp // 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 + if tools[0].Name != "" { + m.Annotations[AnnotationToolNameKey] = tools[0].Name + } + if tools[0].Version != "" { + 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 66c55ebae..1d73cdb8c 100644 --- a/pkg/attestation/crafter/materials/spdxjson_test.go +++ b/pkg/attestation/crafter/materials/spdxjson_test.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-2026 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. @@ -66,12 +66,13 @@ func TestNewSPDXJSONCrafter(t *testing.T) { func TestSPDXJSONCraft(t *testing.T) { testCases := []struct { - name string - filePath string - wantErr string - wantDigest string - wantFilename string - annotations map[string]string + name string + filePath string + wantErr string + wantDigest string + wantFilename string + annotations map[string]string + absentAnnotations []string }{ { name: "invalid sbom format", @@ -99,6 +100,17 @@ func TestSPDXJSONCraft(t *testing.T) { "chainloop.material.tools": `["syft@0.73.0"]`, }, }, + { + name: "tool without version", + filePath: "./testdata/sbom-spdx-no-tool-version.json", + wantDigest: "sha256:c502fcd2944cc7aadf718af910a242cca0702387c27e740229d6e35cd1ccd52e", + wantFilename: "sbom-spdx-no-tool-version.json", + annotations: map[string]string{ + "chainloop.material.tool.name": "spdxgen", + "chainloop.material.tools": `["spdxgen"]`, + }, + absentAnnotations: []string{"chainloop.material.tool.version"}, + }, { name: "multiple tools", filePath: "./testdata/sbom-spdx-multiple-tools.json", @@ -155,6 +167,11 @@ func TestSPDXJSONCraft(t *testing.T) { assert.Equal(v, got.Annotations[k]) } } + + for _, k := range tc.absentAnnotations { + _, exists := got.Annotations[k] + assert.False(exists, "annotation %q should not be present", k) + } }) } } diff --git a/pkg/attestation/crafter/materials/testdata/sbom-spdx-no-tool-version.json b/pkg/attestation/crafter/materials/testdata/sbom-spdx-no-tool-version.json new file mode 100644 index 000000000..e6c2c0d41 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/sbom-spdx-no-tool-version.json @@ -0,0 +1,26 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "test-no-tool-version", + "documentNamespace": "https://example.com/test/no-tool-version", + "creationInfo": { + "licenseListVersion": "3.20", + "creators": [ + "Organization: Example Corp", + "Tool: spdxgen" + ], + "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.4-empty-tool-version.json b/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-1.4-empty-tool-version.json new file mode 100644 index 000000000..15408e998 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-1.4-empty-tool-version.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2025-09-28T07:00:46Z", + "tools": [ + { + "name": "Hub", + "version": "" + } + ], + "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" + } + ] +} diff --git a/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-1.5-empty-tool-version.json b/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-1.5-empty-tool-version.json new file mode 100644 index 000000000..f8261f1d6 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/sbom.cyclonedx-1.5-empty-tool-version.json @@ -0,0 +1,35 @@ +{ + "$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": "" + } + ] + }, + "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" + } + ] +} From d5648981d6f45f68e12bb67d1e42c42d35ecdc7e Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 18 Mar 2026 19:00:41 +0100 Subject: [PATCH 2/2] test(materials): add CSAF and SPDX annotation coverage for empty values Add test for CSAF tool annotation assertions and SPDX empty tool name case to cover the new guards added in the previous commit. Signed-off-by: Jose I. Paris --- .../crafter/materials/csaf_test.go | 27 ++++++++++++++----- .../crafter/materials/spdxjson_test.go | 10 +++++++ .../testdata/sbom-spdx-empty-tool-name.json | 26 ++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 pkg/attestation/crafter/materials/testdata/sbom-spdx-empty-tool-name.json diff --git a/pkg/attestation/crafter/materials/csaf_test.go b/pkg/attestation/crafter/materials/csaf_test.go index 7a67693d3..cb7126adc 100644 --- a/pkg/attestation/crafter/materials/csaf_test.go +++ b/pkg/attestation/crafter/materials/csaf_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 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. @@ -99,11 +99,13 @@ func TestNewCSAFCrafter(t *testing.T) { func TestCSAFCraft(t *testing.T) { testCases := []struct { - name string - filePath string - wantErr string - digest string - schema *contractAPI.CraftingSchema_Material + name string + filePath string + wantErr string + digest string + schema *contractAPI.CraftingSchema_Material + annotations map[string]string + absentAnnotations []string }{ { name: "non-expected json file", @@ -167,6 +169,10 @@ func TestCSAFCraft(t *testing.T) { Name: "test", Type: contractAPI.CraftingSchema_Material_CSAF_VEX, }, + annotations: map[string]string{ + "chainloop.material.tool.name": "Secvisogram", + "chainloop.material.tool.version": "1.11.0", + }, }, { name: "2.0 security advisory", @@ -252,6 +258,15 @@ func TestCSAFCraft(t *testing.T) { assert.Equal(&attestationApi.Attestation_Material_Artifact{ Id: "test", Digest: tc.digest, Name: strings.Split(tc.filePath, "/")[2], }, got.GetArtifact()) + + for k, v := range tc.annotations { + assert.Equal(v, got.Annotations[k]) + } + + for _, k := range tc.absentAnnotations { + _, exists := got.Annotations[k] + assert.False(exists, "annotation %q should not be present", k) + } }) } } diff --git a/pkg/attestation/crafter/materials/spdxjson_test.go b/pkg/attestation/crafter/materials/spdxjson_test.go index 1d73cdb8c..3b82cc763 100644 --- a/pkg/attestation/crafter/materials/spdxjson_test.go +++ b/pkg/attestation/crafter/materials/spdxjson_test.go @@ -100,6 +100,16 @@ func TestSPDXJSONCraft(t *testing.T) { "chainloop.material.tools": `["syft@0.73.0"]`, }, }, + { + name: "tool with empty name", + filePath: "./testdata/sbom-spdx-empty-tool-name.json", + wantDigest: "sha256:bbc5c2f78c7b05f2e80295e1bf827f24623394dafcd56a4a14f0591b835d28fa", + wantFilename: "sbom-spdx-empty-tool-name.json", + absentAnnotations: []string{ + "chainloop.material.tool.name", + "chainloop.material.tool.version", + }, + }, { name: "tool without version", filePath: "./testdata/sbom-spdx-no-tool-version.json", diff --git a/pkg/attestation/crafter/materials/testdata/sbom-spdx-empty-tool-name.json b/pkg/attestation/crafter/materials/testdata/sbom-spdx-empty-tool-name.json new file mode 100644 index 000000000..6e70f74ce --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/sbom-spdx-empty-tool-name.json @@ -0,0 +1,26 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "test-empty-tool-name", + "documentNamespace": "https://example.com/test/empty-tool-name", + "creationInfo": { + "licenseListVersion": "3.20", + "creators": [ + "Organization: Example Corp", + "Tool: " + ], + "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" + } + ] +}