Skip to content

feat(materials): add tool information #1999

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion pkg/attestation/crafter/materials/csaf.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,30 @@ func (i *CSAFCrafter) Craft(ctx context.Context, filepath string) (*api.Attestat
return nil, fmt.Errorf("invalid CSAF file: %w", ErrInvalidMaterialType)
}

return uploadAndCraft(ctx, i.input, i.backend, filepath, i.logger)
m, err := uploadAndCraft(ctx, i.input, i.backend, filepath, i.logger)
if err != nil {
return nil, err
}

i.injectAnnotations(m, documentMap)

return m, nil
}

func (i *CSAFCrafter) injectAnnotations(m *api.Attestation_Material, documentMap map[string]any) {
m.Annotations = make(map[string]string)

// extract vendor info
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 {
m.Annotations[AnnotationToolNameKey] = name
}
if version, ok := engine["version"].(string); ok {
m.Annotations[AnnotationToolVersionKey] = version
}
}
}
}
}
142 changes: 99 additions & 43 deletions pkg/attestation/crafter/materials/cyclonedxjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import (
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/chainloop-dev/chainloop/pkg/casclient"
remotename "github.com/google/go-containerregistry/pkg/name"

"github.com/rs/zerolog"
)

Expand All @@ -43,19 +42,38 @@ type CyclonedxJSONCrafter struct {
*crafterCommon
}

// mainComponentStruct internal struct to unmarshall the incoming CycloneDX JSON
type mainComponentStruct struct {
Metadata struct {
Component struct {
Name string `json:"name"`
Type string `json:"type"`
Version string `json:"version"`
Properties []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"properties"`
} `json:"component"`
} `json:"metadata"`
// cyclonedxDoc internal struct to unmarshall the incoming CycloneDX JSON
type cyclonedxDoc struct {
SpecVersion string `json:"specVersion"`
Metadata json.RawMessage `json:"metadata"`
}

type cyclonedxMetadataV14 struct {
Tools []struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"tools"`
Component cyclonedxComponent `json:"component"`
}

type cyclonedxComponent struct {
Name string `json:"name"`
Type string `json:"type"`
Version string `json:"version"`
Properties []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"properties"`
}

type cyclonedxMetadataV15 struct {
Tools struct {
Components []struct { // available from 1.5 onwards
Name string `json:"name"`
Version string `json:"version"`
} `json:"components"`
} `json:"tools"`
Component cyclonedxComponent `json:"component"`
}

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

// Include the main component information if available
mainComponent, err := i.extractMainComponent(f)
if err != nil {
i.logger.Debug().Err(err).Msg("error extracting main component from sbom, skipping...")
// parse the file to extract the main information
var doc cyclonedxDoc
if err = json.Unmarshal(f, &doc); err != nil {
i.logger.Debug().Err(err).Msg("error decoding file to extract main information, skipping ...")
}

// If the main component is available, include it in the material
if mainComponent != nil {
res.M.(*api.Attestation_Material_SbomArtifact).SbomArtifact.MainComponent = &api.Attestation_Material_SBOMArtifact_MainComponent{
Name: mainComponent.name,
Kind: mainComponent.kind,
Version: mainComponent.version,
switch doc.SpecVersion {
case "1.4":
var metaV14 cyclonedxMetadataV14
if err = json.Unmarshal(doc.Metadata, &metaV14); err != nil {
i.logger.Debug().Err(err).Msg("error decoding file to extract main information, skipping ...")
} else {
i.extractMetadata(m, &metaV14)
}
default: // 1.5 onwards
var metaV15 cyclonedxMetadataV15
if err = json.Unmarshal(doc.Metadata, &metaV15); err != nil {
i.logger.Debug().Err(err).Msg("error decoding file to extract main information, skipping ...")
} else {
i.extractMetadata(m, &metaV15)
}
}

return res, nil
}

// extractMainComponent inspects the SBOM and extracts the main component if any and available
func (i *CyclonedxJSONCrafter) extractMainComponent(rawFile []byte) (*SBOMMainComponentInfo, error) {
var mainComponent mainComponentStruct
err := json.Unmarshal(rawFile, &mainComponent)
if err != nil {
return nil, fmt.Errorf("error extracting main component: %w", err)
func (i *CyclonedxJSONCrafter) extractMetadata(m *api.Attestation_Material, metadata any) {
m.Annotations = make(map[string]string)

switch meta := metadata.(type) {
case *cyclonedxMetadataV14:
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) > 0 {
m.Annotations[AnnotationToolNameKey] = meta.Tools[0].Name
m.Annotations[AnnotationToolVersionKey] = meta.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
}
default:
i.logger.Debug().Msg("unknown metadata version")
}
}

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

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

if component.Type != containerComponentKind {
return &SBOMMainComponentInfo{
mainComponent = &SBOMMainComponentInfo{
name: component.Name,
kind: component.Type,
version: component.Version,
}, nil
}
} else {
// Standardize the name to have the full repository name including the registry and
// sanitize the name to remove the possible tag from the repository name
ref, err := remotename.ParseReference(component.Name)
if err != nil {
return fmt.Errorf("couldn't parse OCI image repository name: %w", err)
}

mainComponent = &SBOMMainComponentInfo{
name: ref.Context().String(),
kind: component.Type,
version: component.Version,
}
}

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

return &SBOMMainComponentInfo{
name: ref.Context().String(),
kind: component.Type,
version: component.Version,
}, nil
return nil
}
15 changes: 15 additions & 0 deletions pkg/attestation/crafter/materials/cyclonedxjson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func TestCyclonedxJSONCraft(t *testing.T) {
wantMainComponent string
wantMainComponentKind string
wantMainComponentVersion string
annotations map[string]string
}{
{
name: "invalid path",
Expand All @@ -96,6 +97,10 @@ func TestCyclonedxJSONCraft(t *testing.T) {
wantFilename: "sbom.cyclonedx.json",
wantMainComponent: ".",
wantMainComponentKind: "file",
annotations: map[string]string{
"chainloop.material.tool.name": "syft",
"chainloop.material.tool.version": "0.73.0",
},
},
{
name: "1.5 version",
Expand All @@ -105,6 +110,10 @@ func TestCyclonedxJSONCraft(t *testing.T) {
wantMainComponent: "ghcr.io/chainloop-dev/chainloop/control-plane",
wantMainComponentKind: "container",
wantMainComponentVersion: "v0.55.0",
annotations: map[string]string{
"chainloop.material.tool.name": "syft",
"chainloop.material.tool.version": "0.101.1",
},
},
}

Expand Down Expand Up @@ -151,6 +160,12 @@ func TestCyclonedxJSONCraft(t *testing.T) {
},
got.GetSbomArtifact(),
)

if tc.annotations != nil {
for k, v := range tc.annotations {
ast.Equal(v, got.Annotations[k])
}
}
})
}
}
7 changes: 6 additions & 1 deletion pkg/attestation/crafter/materials/materials.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)

const AnnotationToolNameKey = "chainloop.material.tool.name"
const AnnotationToolVersionKey = "chainloop.material.tool.version"

var (
// ErrInvalidMaterialType is returned when the provided material type
// is not from the kind we are expecting
Expand Down Expand Up @@ -232,7 +235,9 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia
}

m.AddedAt = timestamppb.New(time.Now())
m.Annotations = make(map[string]string)
if m.Annotations == nil {
m.Annotations = make(map[string]string)
}

for _, annotation := range materialSchema.Annotations {
m.Annotations[annotation.Name] = annotation.Value
Expand Down
21 changes: 20 additions & 1 deletion pkg/attestation/crafter/materials/sarif.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,24 @@ func (i *SARIFCrafter) Craft(ctx context.Context, filepath string) (*api.Attesta
return nil, fmt.Errorf("invalid SARIF file: %w", ErrInvalidMaterialType)
}

return uploadAndCraft(ctx, i.input, i.backend, filepath, i.logger)
m, err := uploadAndCraft(ctx, i.input, i.backend, filepath, i.logger)
if err != nil {
return nil, err
}

i.injectAnnotations(m, doc)

return m, nil
}

func (i *SARIFCrafter) injectAnnotations(m *api.Attestation_Material, doc *sarif.Report) {
// add vendor information
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 {
m.Annotations[AnnotationToolVersionKey] = *doc.Runs[0].Tool.Driver.Version
}
}
}
31 changes: 29 additions & 2 deletions pkg/attestation/crafter/materials/spdxjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import (
"context"
"fmt"
"os"
"strings"

schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/chainloop-dev/chainloop/pkg/casclient"
"github.com/spdx/tools-golang/json"
"github.com/spdx/tools-golang/spdx"

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

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

return uploadAndCraft(ctx, i.input, i.backend, filePath, i.logger)
m, err := uploadAndCraft(ctx, i.input, i.backend, filePath, i.logger)
if err != nil {
return nil, err
}

i.injectAnnotations(m, doc)

return m, nil
}

func (i *SPDXJSONCrafter) injectAnnotations(m *api.Attestation_Material, doc *spdx.Document) {
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]
}
break
}
}
}
Loading