Skip to content

Commit 67fc292

Browse files
authored
feat(crafter): support multiple tools in chainloop metadata (#2481)
Signed-off-by: Sylwester Piskozub <sylwesterpiskozub@gmail.com>
1 parent ed330d0 commit 67fc292

File tree

9 files changed

+203
-21
lines changed

9 files changed

+203
-21
lines changed

app/cli/cmd/attestation_add.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/chainloop-dev/chainloop/app/cli/cmd/output"
3030
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
3131
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
32+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
3233
"github.com/chainloop-dev/chainloop/pkg/resourceloader"
3334
)
3435

@@ -194,6 +195,9 @@ func displayMaterialInfo(status *action.AttestationStatusMaterial, policyEvaluat
194195
if len(status.Material.Annotations) > 0 {
195196
mt.AppendRow(table.Row{"Annotations", "------"})
196197
for _, a := range status.Material.Annotations {
198+
if materials.IsLegacyAnnotation(a.Name) {
199+
continue
200+
}
197201
value := a.Value
198202
if value == "" {
199203
value = NotSet

app/cli/cmd/attestation_status.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030

3131
"github.com/chainloop-dev/chainloop/app/cli/cmd/output"
3232
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
33+
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
3334
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
3435
)
3536

@@ -114,6 +115,9 @@ func attestationStatusTableOutput(status *action.AttestationStatusResult, w io.W
114115
if len(status.Annotations) > 0 {
115116
gt.AppendRow(table.Row{"Annotations", "------"})
116117
for _, a := range status.Annotations {
118+
if materials.IsLegacyAnnotation(a.Name) {
119+
continue
120+
}
117121
value := a.Value
118122
if value == "" {
119123
value = NotSet
@@ -220,6 +224,9 @@ func materialsTable(status *action.AttestationStatusResult, w io.Writer, full bo
220224
if len(m.Annotations) > 0 {
221225
mt.AppendRow(table.Row{"Annotations", "------"})
222226
for _, a := range m.Annotations {
227+
if materials.IsLegacyAnnotation(a.Name) {
228+
continue
229+
}
223230
value := a.Value
224231
if value == "" {
225232
value = NotSet

pkg/attestation/crafter/materials/cyclonedxjson.go

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,19 +166,37 @@ func (i *CyclonedxJSONCrafter) extractMetadata(m *api.Attestation_Material, meta
166166
i.logger.Debug().Err(err).Msg("error extracting main component from sbom, skipping...")
167167
}
168168

169-
if len(meta.Tools) > 0 {
170-
m.Annotations[AnnotationToolNameKey] = meta.Tools[0].Name
171-
m.Annotations[AnnotationToolVersionKey] = meta.Tools[0].Version
169+
// Extract all tools and set annotations
170+
var tools []Tool
171+
for _, tool := range meta.Tools {
172+
tools = append(tools, Tool{Name: tool.Name, Version: tool.Version})
172173
}
174+
SetToolsAnnotation(m, tools)
175+
176+
// Maintain backward compatibility - keep legacy keys for the first tool
177+
if len(tools) > 0 {
178+
m.Annotations[AnnotationToolNameKey] = tools[0].Name
179+
m.Annotations[AnnotationToolVersionKey] = tools[0].Version
180+
}
181+
173182
case *cyclonedxMetadataV15:
174183
if err := i.extractMainComponent(m, &meta.Component); err != nil {
175184
i.logger.Debug().Err(err).Msg("error extracting main component from sbom, skipping...")
176185
}
177186

178-
if len(meta.Tools.Components) > 0 {
179-
m.Annotations[AnnotationToolNameKey] = meta.Tools.Components[0].Name
180-
m.Annotations[AnnotationToolVersionKey] = meta.Tools.Components[0].Version
187+
// Extract all tools and set annotations
188+
var tools []Tool
189+
for _, tool := range meta.Tools.Components {
190+
tools = append(tools, Tool{Name: tool.Name, Version: tool.Version})
181191
}
192+
SetToolsAnnotation(m, tools)
193+
194+
// Maintain backward compatibility - keep legacy keys for the first tool
195+
if len(tools) > 0 {
196+
m.Annotations[AnnotationToolNameKey] = tools[0].Name
197+
m.Annotations[AnnotationToolVersionKey] = tools[0].Version
198+
}
199+
182200
default:
183201
i.logger.Debug().Msg("unknown metadata version")
184202
}

pkg/attestation/crafter/materials/cyclonedxjson_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,20 @@ func TestCyclonedxJSONCraft(t *testing.T) {
156156
"chainloop.material.sbom.vulnerabilities_report": "true",
157157
},
158158
},
159+
{
160+
name: "1.5 version with multiple tools",
161+
filePath: "./testdata/sbom.cyclonedx-1.5-multiple-tools.json",
162+
wantDigest: "sha256:56f82c99fb4740f952296705ceb2ee0c5c3c6a3309b35373d542d58878d65cd3",
163+
wantFilename: "sbom.cyclonedx-1.5-multiple-tools.json",
164+
wantMainComponent: "test-app",
165+
wantMainComponentKind: "application",
166+
wantMainComponentVersion: "1.0.0",
167+
annotations: map[string]string{
168+
"chainloop.material.tool.name": "Hub",
169+
"chainloop.material.tool.version": "2025.4.2",
170+
"chainloop.material.tools": `["Hub@2025.4.2","cyclonedx-core-java@5.0.5"]`,
171+
},
172+
},
159173
}
160174

161175
schema := &contractAPI.CraftingSchema_Material{

pkg/attestation/crafter/materials/materials.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package materials
1717

1818
import (
1919
"context"
20+
"encoding/json"
2021
"errors"
2122
"fmt"
2223
"io"
@@ -36,6 +37,40 @@ import (
3637

3738
const AnnotationToolNameKey = "chainloop.material.tool.name"
3839
const AnnotationToolVersionKey = "chainloop.material.tool.version"
40+
const AnnotationToolsKey = "chainloop.material.tools"
41+
42+
// IsLegacyAnnotation returns true if the annotation key is a legacy annotation
43+
func IsLegacyAnnotation(key string) bool {
44+
return key == AnnotationToolNameKey || key == AnnotationToolVersionKey
45+
}
46+
47+
// Tool represents a tool with name and version
48+
type Tool struct {
49+
Name string
50+
Version string
51+
}
52+
53+
// SetToolsAnnotations sets the tools annotation as a JSON array in "name@version" format
54+
func SetToolsAnnotation(m *api.Attestation_Material, tools []Tool) {
55+
if len(tools) == 0 {
56+
return
57+
}
58+
59+
// Build array of "name@version" strings
60+
toolStrings := make([]string, 0, len(tools))
61+
for _, tool := range tools {
62+
toolStr := tool.Name
63+
if tool.Version != "" {
64+
toolStr = fmt.Sprintf("%s@%s", tool.Name, tool.Version)
65+
}
66+
toolStrings = append(toolStrings, toolStr)
67+
}
68+
69+
// Marshal to JSON array
70+
if toolsJSON, err := json.Marshal(toolStrings); err == nil {
71+
m.Annotations[AnnotationToolsKey] = string(toolsJSON)
72+
}
73+
}
3974

4075
var (
4176
// ErrInvalidMaterialType is returned when the provided material type

pkg/attestation/crafter/materials/spdxjson.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,27 @@ func (i *SPDXJSONCrafter) Craft(ctx context.Context, filePath string) (*api.Atte
7171
}
7272

7373
func (i *SPDXJSONCrafter) injectAnnotations(m *api.Attestation_Material, doc *spdx.Document) {
74+
m.Annotations = make(map[string]string)
75+
76+
// Extract all tools from the creators array
77+
var tools []Tool
7478
for _, c := range doc.CreationInfo.Creators {
7579
if c.CreatorType == "Tool" {
76-
m.Annotations = make(map[string]string)
77-
m.Annotations[AnnotationToolNameKey] = c.Creator
78-
7980
// try to extract the tool name and version
8081
// e.g. "myTool-1.0.0"
81-
parts := strings.SplitN(c.Creator, "-", 2)
82-
if len(parts) == 2 {
83-
m.Annotations[AnnotationToolNameKey] = parts[0]
84-
m.Annotations[AnnotationToolVersionKey] = parts[1]
82+
name, version := c.Creator, ""
83+
if parts := strings.SplitN(c.Creator, "-", 2); len(parts) == 2 {
84+
name, version = parts[0], parts[1]
8585
}
86-
break
86+
tools = append(tools, Tool{Name: name, Version: version})
8787
}
8888
}
89+
90+
SetToolsAnnotation(m, tools)
91+
92+
// Maintain backward compatibility - keep legacy keys for the first tool
93+
if len(tools) > 0 {
94+
m.Annotations[AnnotationToolNameKey] = tools[0].Name
95+
m.Annotations[AnnotationToolVersionKey] = tools[0].Version
96+
}
8997
}

pkg/attestation/crafter/materials/spdxjson_test.go

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,12 @@ func TestNewSPDXJSONCrafter(t *testing.T) {
6666

6767
func TestSPDXJSONCraft(t *testing.T) {
6868
testCases := []struct {
69-
name string
70-
filePath string
71-
wantErr string
69+
name string
70+
filePath string
71+
wantErr string
72+
wantDigest string
73+
wantFilename string
74+
annotations map[string]string
7275
}{
7376
{
7477
name: "invalid sbom format",
@@ -86,8 +89,26 @@ func TestSPDXJSONCraft(t *testing.T) {
8689
wantErr: "unexpected material type",
8790
},
8891
{
89-
name: "valid artifact type",
90-
filePath: "./testdata/sbom-spdx.json",
92+
name: "valid artifact type",
93+
filePath: "./testdata/sbom-spdx.json",
94+
wantDigest: "sha256:fe2636fb6c698a29a315278b762b2000efd5959afe776ee4f79f1ed523365a33",
95+
wantFilename: "sbom-spdx.json",
96+
annotations: map[string]string{
97+
"chainloop.material.tool.name": "syft",
98+
"chainloop.material.tool.version": "0.73.0",
99+
"chainloop.material.tools": `["syft@0.73.0"]`,
100+
},
101+
},
102+
{
103+
name: "multiple tools",
104+
filePath: "./testdata/sbom-spdx-multiple-tools.json",
105+
wantDigest: "sha256:c1a61566c7c0224ac02ad9cd21d90234e5a71de26971e33df2205c1a2eb319fc",
106+
wantFilename: "sbom-spdx-multiple-tools.json",
107+
annotations: map[string]string{
108+
"chainloop.material.tool.name": "spdxgen",
109+
"chainloop.material.tool.version": "1.0.0",
110+
"chainloop.material.tools": `["spdxgen@1.0.0","scanner@2.1.5"]`,
111+
},
91112
},
92113
}
93114

@@ -123,10 +144,17 @@ func TestSPDXJSONCraft(t *testing.T) {
123144
assert.Equal(contractAPI.CraftingSchema_Material_SBOM_SPDX_JSON.String(), got.MaterialType.String())
124145
assert.True(got.UploadedToCas)
125146

126-
// // The result includes the digest reference
147+
// The result includes the digest reference
127148
assert.Equal(got.GetArtifact(), &attestationApi.Attestation_Material_Artifact{
128-
Id: "test", Digest: "sha256:fe2636fb6c698a29a315278b762b2000efd5959afe776ee4f79f1ed523365a33", Name: "sbom-spdx.json",
149+
Id: "test", Digest: tc.wantDigest, Name: tc.wantFilename,
129150
})
151+
152+
// Validate annotations if specified
153+
if tc.annotations != nil {
154+
for k, v := range tc.annotations {
155+
assert.Equal(v, got.Annotations[k])
156+
}
157+
}
130158
})
131159
}
132160
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"spdxVersion": "SPDX-2.3",
3+
"dataLicense": "CC0-1.0",
4+
"SPDXID": "SPDXRef-DOCUMENT",
5+
"name": "test-multiple-tools",
6+
"documentNamespace": "https://example.com/test/multiple-tools",
7+
"creationInfo": {
8+
"licenseListVersion": "3.20",
9+
"creators": [
10+
"Organization: Example Corp",
11+
"Tool: spdxgen-1.0.0",
12+
"Tool: scanner-2.1.5"
13+
],
14+
"created": "2024-01-01T10:00:00Z"
15+
},
16+
"packages": [
17+
{
18+
"name": "example-package",
19+
"SPDXID": "SPDXRef-Package-example",
20+
"versionInfo": "1.0.0",
21+
"downloadLocation": "NOASSERTION",
22+
"licenseConcluded": "MIT",
23+
"licenseDeclared": "MIT",
24+
"copyrightText": "NOASSERTION"
25+
}
26+
]
27+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3+
"bomFormat": "CycloneDX",
4+
"specVersion": "1.5",
5+
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
6+
"version": 1,
7+
"metadata": {
8+
"timestamp": "2025-09-28T07:00:46Z",
9+
"tools": {
10+
"components": [
11+
{
12+
"type": "application",
13+
"author": "Black Duck",
14+
"name": "Hub",
15+
"version": "2025.4.2"
16+
},
17+
{
18+
"type": "library",
19+
"author": "CycloneDX",
20+
"name": "cyclonedx-core-java",
21+
"version": "5.0.5"
22+
}
23+
]
24+
},
25+
"component": {
26+
"bom-ref": "test-component",
27+
"type": "application",
28+
"name": "test-app",
29+
"version": "1.0.0"
30+
}
31+
},
32+
"components": [
33+
{
34+
"bom-ref": "pkg:golang/example.com/test@v1.0.0",
35+
"type": "library",
36+
"name": "example.com/test",
37+
"version": "v1.0.0",
38+
"purl": "pkg:golang/example.com/test@v1.0.0"
39+
}
40+
]
41+
}

0 commit comments

Comments
 (0)