Skip to content

Commit 711f466

Browse files
authored
feat(materials): add tool information (#1999)
Signed-off-by: Jose I. Paris <[email protected]>
1 parent 31eac55 commit 711f466

File tree

6 files changed

+195
-48
lines changed

6 files changed

+195
-48
lines changed

pkg/attestation/crafter/materials/csaf.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,5 +129,30 @@ func (i *CSAFCrafter) Craft(ctx context.Context, filepath string) (*api.Attestat
129129
return nil, fmt.Errorf("invalid CSAF file: %w", ErrInvalidMaterialType)
130130
}
131131

132-
return uploadAndCraft(ctx, i.input, i.backend, filepath, i.logger)
132+
m, err := uploadAndCraft(ctx, i.input, i.backend, filepath, i.logger)
133+
if err != nil {
134+
return nil, err
135+
}
136+
137+
i.injectAnnotations(m, documentMap)
138+
139+
return m, nil
140+
}
141+
142+
func (i *CSAFCrafter) injectAnnotations(m *api.Attestation_Material, documentMap map[string]any) {
143+
m.Annotations = make(map[string]string)
144+
145+
// extract vendor info
146+
if tracking, ok := documentMap["tracking"].(map[string]any); ok {
147+
if generator, ok := tracking["generator"].(map[string]any); ok {
148+
if engine, ok := generator["engine"].(map[string]any); ok {
149+
if name, ok := engine["name"].(string); ok {
150+
m.Annotations[AnnotationToolNameKey] = name
151+
}
152+
if version, ok := engine["version"].(string); ok {
153+
m.Annotations[AnnotationToolVersionKey] = version
154+
}
155+
}
156+
}
157+
}
133158
}

pkg/attestation/crafter/materials/cyclonedxjson.go

Lines changed: 99 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import (
2727
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2828
"github.com/chainloop-dev/chainloop/pkg/casclient"
2929
remotename "github.com/google/go-containerregistry/pkg/name"
30-
3130
"github.com/rs/zerolog"
3231
)
3332

@@ -43,19 +42,38 @@ type CyclonedxJSONCrafter struct {
4342
*crafterCommon
4443
}
4544

46-
// mainComponentStruct internal struct to unmarshall the incoming CycloneDX JSON
47-
type mainComponentStruct struct {
48-
Metadata struct {
49-
Component struct {
50-
Name string `json:"name"`
51-
Type string `json:"type"`
52-
Version string `json:"version"`
53-
Properties []struct {
54-
Name string `json:"name"`
55-
Value string `json:"value"`
56-
} `json:"properties"`
57-
} `json:"component"`
58-
} `json:"metadata"`
45+
// cyclonedxDoc internal struct to unmarshall the incoming CycloneDX JSON
46+
type cyclonedxDoc struct {
47+
SpecVersion string `json:"specVersion"`
48+
Metadata json.RawMessage `json:"metadata"`
49+
}
50+
51+
type cyclonedxMetadataV14 struct {
52+
Tools []struct {
53+
Name string `json:"name"`
54+
Version string `json:"version"`
55+
} `json:"tools"`
56+
Component cyclonedxComponent `json:"component"`
57+
}
58+
59+
type cyclonedxComponent struct {
60+
Name string `json:"name"`
61+
Type string `json:"type"`
62+
Version string `json:"version"`
63+
Properties []struct {
64+
Name string `json:"name"`
65+
Value string `json:"value"`
66+
} `json:"properties"`
67+
}
68+
69+
type cyclonedxMetadataV15 struct {
70+
Tools struct {
71+
Components []struct { // available from 1.5 onwards
72+
Name string `json:"name"`
73+
Version string `json:"version"`
74+
} `json:"components"`
75+
} `json:"tools"`
76+
Component cyclonedxComponent `json:"component"`
5977
}
6078

6179
func NewCyclonedxJSONCrafter(materialSchema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) (*CyclonedxJSONCrafter, error) {
@@ -100,33 +118,62 @@ func (i *CyclonedxJSONCrafter) Craft(ctx context.Context, filePath string) (*api
100118
},
101119
}
102120

103-
// Include the main component information if available
104-
mainComponent, err := i.extractMainComponent(f)
105-
if err != nil {
106-
i.logger.Debug().Err(err).Msg("error extracting main component from sbom, skipping...")
121+
// parse the file to extract the main information
122+
var doc cyclonedxDoc
123+
if err = json.Unmarshal(f, &doc); err != nil {
124+
i.logger.Debug().Err(err).Msg("error decoding file to extract main information, skipping ...")
107125
}
108126

109-
// If the main component is available, include it in the material
110-
if mainComponent != nil {
111-
res.M.(*api.Attestation_Material_SbomArtifact).SbomArtifact.MainComponent = &api.Attestation_Material_SBOMArtifact_MainComponent{
112-
Name: mainComponent.name,
113-
Kind: mainComponent.kind,
114-
Version: mainComponent.version,
127+
switch doc.SpecVersion {
128+
case "1.4":
129+
var metaV14 cyclonedxMetadataV14
130+
if err = json.Unmarshal(doc.Metadata, &metaV14); err != nil {
131+
i.logger.Debug().Err(err).Msg("error decoding file to extract main information, skipping ...")
132+
} else {
133+
i.extractMetadata(m, &metaV14)
134+
}
135+
default: // 1.5 onwards
136+
var metaV15 cyclonedxMetadataV15
137+
if err = json.Unmarshal(doc.Metadata, &metaV15); err != nil {
138+
i.logger.Debug().Err(err).Msg("error decoding file to extract main information, skipping ...")
139+
} else {
140+
i.extractMetadata(m, &metaV15)
115141
}
116142
}
117143

118144
return res, nil
119145
}
120146

121-
// extractMainComponent inspects the SBOM and extracts the main component if any and available
122-
func (i *CyclonedxJSONCrafter) extractMainComponent(rawFile []byte) (*SBOMMainComponentInfo, error) {
123-
var mainComponent mainComponentStruct
124-
err := json.Unmarshal(rawFile, &mainComponent)
125-
if err != nil {
126-
return nil, fmt.Errorf("error extracting main component: %w", err)
147+
func (i *CyclonedxJSONCrafter) extractMetadata(m *api.Attestation_Material, metadata any) {
148+
m.Annotations = make(map[string]string)
149+
150+
switch meta := metadata.(type) {
151+
case *cyclonedxMetadataV14:
152+
if err := i.extractMainComponent(m, &meta.Component); err != nil {
153+
i.logger.Debug().Err(err).Msg("error extracting main component from sbom, skipping...")
154+
}
155+
156+
if len(meta.Tools) > 0 {
157+
m.Annotations[AnnotationToolNameKey] = meta.Tools[0].Name
158+
m.Annotations[AnnotationToolVersionKey] = meta.Tools[0].Version
159+
}
160+
case *cyclonedxMetadataV15:
161+
if err := i.extractMainComponent(m, &meta.Component); err != nil {
162+
i.logger.Debug().Err(err).Msg("error extracting main component from sbom, skipping...")
163+
}
164+
165+
if len(meta.Tools.Components) > 0 {
166+
m.Annotations[AnnotationToolNameKey] = meta.Tools.Components[0].Name
167+
m.Annotations[AnnotationToolVersionKey] = meta.Tools.Components[0].Version
168+
}
169+
default:
170+
i.logger.Debug().Msg("unknown metadata version")
127171
}
172+
}
128173

129-
component := mainComponent.Metadata.Component
174+
// extractMainComponent inspects the SBOM and extracts the main component if any and available
175+
func (i *CyclonedxJSONCrafter) extractMainComponent(m *api.Attestation_Material, component *cyclonedxComponent) error {
176+
var mainComponent *SBOMMainComponentInfo
130177

131178
// If the version is empty, try to extract it from the properties
132179
if component.Version == "" {
@@ -141,23 +188,32 @@ func (i *CyclonedxJSONCrafter) extractMainComponent(rawFile []byte) (*SBOMMainCo
141188
}
142189

143190
if component.Type != containerComponentKind {
144-
return &SBOMMainComponentInfo{
191+
mainComponent = &SBOMMainComponentInfo{
145192
name: component.Name,
146193
kind: component.Type,
147194
version: component.Version,
148-
}, nil
195+
}
196+
} else {
197+
// Standardize the name to have the full repository name including the registry and
198+
// sanitize the name to remove the possible tag from the repository name
199+
ref, err := remotename.ParseReference(component.Name)
200+
if err != nil {
201+
return fmt.Errorf("couldn't parse OCI image repository name: %w", err)
202+
}
203+
204+
mainComponent = &SBOMMainComponentInfo{
205+
name: ref.Context().String(),
206+
kind: component.Type,
207+
version: component.Version,
208+
}
149209
}
150210

151-
// Standardize the name to have the full repository name including the registry and
152-
// sanitize the name to remove the possible tag from the repository name
153-
ref, err := remotename.ParseReference(component.Name)
154-
if err != nil {
155-
return nil, fmt.Errorf("couldn't parse OCI image repository name: %w", err)
211+
// If the main component is available, include it in the material
212+
m.M.(*api.Attestation_Material_SbomArtifact).SbomArtifact.MainComponent = &api.Attestation_Material_SBOMArtifact_MainComponent{
213+
Name: mainComponent.name,
214+
Kind: mainComponent.kind,
215+
Version: mainComponent.version,
156216
}
157217

158-
return &SBOMMainComponentInfo{
159-
name: ref.Context().String(),
160-
kind: component.Type,
161-
version: component.Version,
162-
}, nil
218+
return nil
163219
}

pkg/attestation/crafter/materials/cyclonedxjson_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func TestCyclonedxJSONCraft(t *testing.T) {
7373
wantMainComponent string
7474
wantMainComponentKind string
7575
wantMainComponentVersion string
76+
annotations map[string]string
7677
}{
7778
{
7879
name: "invalid path",
@@ -96,6 +97,10 @@ func TestCyclonedxJSONCraft(t *testing.T) {
9697
wantFilename: "sbom.cyclonedx.json",
9798
wantMainComponent: ".",
9899
wantMainComponentKind: "file",
100+
annotations: map[string]string{
101+
"chainloop.material.tool.name": "syft",
102+
"chainloop.material.tool.version": "0.73.0",
103+
},
99104
},
100105
{
101106
name: "1.5 version",
@@ -105,6 +110,10 @@ func TestCyclonedxJSONCraft(t *testing.T) {
105110
wantMainComponent: "ghcr.io/chainloop-dev/chainloop/control-plane",
106111
wantMainComponentKind: "container",
107112
wantMainComponentVersion: "v0.55.0",
113+
annotations: map[string]string{
114+
"chainloop.material.tool.name": "syft",
115+
"chainloop.material.tool.version": "0.101.1",
116+
},
108117
},
109118
}
110119

@@ -151,6 +160,12 @@ func TestCyclonedxJSONCraft(t *testing.T) {
151160
},
152161
got.GetSbomArtifact(),
153162
)
163+
164+
if tc.annotations != nil {
165+
for k, v := range tc.annotations {
166+
ast.Equal(v, got.Annotations[k])
167+
}
168+
}
154169
})
155170
}
156171
}

pkg/attestation/crafter/materials/materials.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ import (
3434
"google.golang.org/protobuf/types/known/timestamppb"
3535
)
3636

37+
const AnnotationToolNameKey = "chainloop.material.tool.name"
38+
const AnnotationToolVersionKey = "chainloop.material.tool.version"
39+
3740
var (
3841
// ErrInvalidMaterialType is returned when the provided material type
3942
// is not from the kind we are expecting
@@ -232,7 +235,9 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia
232235
}
233236

234237
m.AddedAt = timestamppb.New(time.Now())
235-
m.Annotations = make(map[string]string)
238+
if m.Annotations == nil {
239+
m.Annotations = make(map[string]string)
240+
}
236241

237242
for _, annotation := range materialSchema.Annotations {
238243
m.Annotations[annotation.Name] = annotation.Value

pkg/attestation/crafter/materials/sarif.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,24 @@ func (i *SARIFCrafter) Craft(ctx context.Context, filepath string) (*api.Attesta
5454
return nil, fmt.Errorf("invalid SARIF file: %w", ErrInvalidMaterialType)
5555
}
5656

57-
return uploadAndCraft(ctx, i.input, i.backend, filepath, i.logger)
57+
m, err := uploadAndCraft(ctx, i.input, i.backend, filepath, i.logger)
58+
if err != nil {
59+
return nil, err
60+
}
61+
62+
i.injectAnnotations(m, doc)
63+
64+
return m, nil
65+
}
66+
67+
func (i *SARIFCrafter) injectAnnotations(m *api.Attestation_Material, doc *sarif.Report) {
68+
// add vendor information
69+
if len(doc.Runs) > 0 {
70+
// assuming vendor from first run.
71+
m.Annotations = make(map[string]string)
72+
m.Annotations[AnnotationToolNameKey] = doc.Runs[0].Tool.Driver.Name
73+
if doc.Runs[0].Tool.Driver.Version != nil {
74+
m.Annotations[AnnotationToolVersionKey] = *doc.Runs[0].Tool.Driver.Version
75+
}
76+
}
5877
}

pkg/attestation/crafter/materials/spdxjson.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ import (
1919
"context"
2020
"fmt"
2121
"os"
22+
"strings"
2223

2324
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2425
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2526
"github.com/chainloop-dev/chainloop/pkg/casclient"
2627
"github.com/spdx/tools-golang/json"
28+
"github.com/spdx/tools-golang/spdx"
2729

2830
"github.com/rs/zerolog"
2931
)
@@ -52,11 +54,36 @@ func (i *SPDXJSONCrafter) Craft(ctx context.Context, filePath string) (*api.Atte
5254
defer f.Close()
5355

5456
// Decode the file to check it's a valid SPDX BOM
55-
_, err = json.Read(f)
57+
doc, err := json.Read(f)
5658
if err != nil {
5759
i.logger.Debug().Err(err).Msg("error decoding file")
5860
return nil, fmt.Errorf("invalid spdx sbom file: %w", ErrInvalidMaterialType)
5961
}
6062

61-
return uploadAndCraft(ctx, i.input, i.backend, filePath, i.logger)
63+
m, err := uploadAndCraft(ctx, i.input, i.backend, filePath, i.logger)
64+
if err != nil {
65+
return nil, err
66+
}
67+
68+
i.injectAnnotations(m, doc)
69+
70+
return m, nil
71+
}
72+
73+
func (i *SPDXJSONCrafter) injectAnnotations(m *api.Attestation_Material, doc *spdx.Document) {
74+
for _, c := range doc.CreationInfo.Creators {
75+
if c.CreatorType == "Tool" {
76+
m.Annotations = make(map[string]string)
77+
m.Annotations[AnnotationToolNameKey] = c.Creator
78+
79+
// try to extract the tool name and version
80+
// 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]
85+
}
86+
break
87+
}
88+
}
6289
}

0 commit comments

Comments
 (0)