Skip to content

feat(cyclonedx): Include sbom main component info for Trivy #1991

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 4 commits into from
Apr 16, 2025
Merged
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
51 changes: 36 additions & 15 deletions pkg/attestation/crafter/materials/cyclonedxjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,33 @@ import (
"github.com/rs/zerolog"
)

// containerComponentKind is the kind of the main component when it's a container
const containerComponentKind = "container"
const (
// containerComponentKind is the kind of the main component when it's a container
containerComponentKind = "container"
// aquaTrivyRepoDigestPropertyKey is the key used by Aqua Trivy to store the repo digest
aquaTrivyRepoDigestPropertyKey = "aquasecurity:trivy:RepoDigest"
)

type CyclonedxJSONCrafter struct {
backend *casclient.CASBackend
*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"`
}

func NewCyclonedxJSONCrafter(materialSchema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) (*CyclonedxJSONCrafter, error) {
if materialSchema.Type != schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON {
return nil, fmt.Errorf("material type is not cyclonedx json")
Expand Down Expand Up @@ -101,24 +120,26 @@ func (i *CyclonedxJSONCrafter) Craft(ctx context.Context, filePath string) (*api

// extractMainComponent inspects the SBOM and extracts the main component if any and available
func (i *CyclonedxJSONCrafter) extractMainComponent(rawFile []byte) (*SBOMMainComponentInfo, error) {
// Define the structure of the main component in the SBOM locally to perform an unmarshal
type mainComponentStruct struct {
Metadata struct {
Component struct {
Name string `json:"name"`
Type string `json:"type"`
Version string `json:"version"`
} `json:"component"`
} `json:"metadata"`
}

var mainComponent mainComponentStruct
err := json.Unmarshal(rawFile, &mainComponent)
if err != nil {
return nil, fmt.Errorf("error extracting main component: %w", err)
}

component := mainComponent.Metadata.Component

// If the version is empty, try to extract it from the properties
if component.Version == "" {
for _, prop := range component.Properties {
if prop.Name == aquaTrivyRepoDigestPropertyKey {
if parts := strings.Split(prop.Value, "sha256:"); len(parts) == 2 {
component.Version = fmt.Sprintf("sha256:%s", parts[1])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this solving only the version or the info about the main component as well?

image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This solves the issue of CYCLONEDX files from Trivy where the version was not being included, the sbom was generated by Trivy:

$ trivy image ghcr.io/chainloop-dev/chainloop/cli:latest --output sbom.json --format cyclonedx
$ chainloop wf run describe --id 866b750c-80e2-422e-ad09-1f4db049d9b8 -o statement | jq '.predicate.materials.[].annotations'
{
  "chainloop.material.cas.inline": true,
  "chainloop.material.name": "material-1744782814076087000",
  "chainloop.material.sbom.main_component.name": "ghcr.io/chainloop-dev/chainloop/cli",
  "chainloop.material.sbom.main_component.type": "container",
  "chainloop.material.sbom.main_component.version": "sha256:bbfd27fcdb15c8082951dc59be2310a2a2e6b95e11002f8411e5918887faa607",
  "chainloop.material.type": "SBOM_CYCLONEDX_JSON"
}

Keep in mind the information about the main component is not required in the CycloneDX specification is not required so it might be cases where the information is not populated and tools can behave differently.

This is an example of Trivy scanning the local system:

$ trivy filesystem ./app/cli --output sbom.json --format cyclonedx
$ chainloop wf run describe --id af935022-4a2c-4043-a2de-abd3431ed504 -o statement | jq '.predicate.materials.[].annotations'
WRN API contacted in insecure mode
{
  "chainloop.material.cas.inline": true,
  "chainloop.material.name": "material-1744784071174128000",
  "chainloop.material.sbom.main_component.name": "app/cli",
  "chainloop.material.sbom.main_component.type": "application",
  "chainloop.material.sbom.main_component.version": "",
  "chainloop.material.type": "SBOM_CYCLONEDX_JSON"
}

And this is syft:

$ syft scan ./app/cli --output cyclonedx-json > sbom.json
$ chainloop wf run describe --id 2c320286-f088-46f8-9095-3576be6b3c0b -o statement | jq '.predicate.materials.[].annotations'
WRN API contacted in insecure mode
{
  "chainloop.material.cas.inline": true,
  "chainloop.material.name": "material-1744784201547639000",
  "chainloop.material.sbom.main_component.name": "./app/cli",
  "chainloop.material.sbom.main_component.type": "file",
  "chainloop.material.sbom.main_component.version": "",
  "chainloop.material.type": "SBOM_CYCLONEDX_JSON"
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the tests that I've run (container, and filesystem), the main component information was populated. Do you have a way to reproduce the one that was not being added?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I found an example, having a look, it seems there is another thing involved.

break
}
}
}
}

if component.Type != containerComponentKind {
return &SBOMMainComponentInfo{
name: component.Name,
Expand All @@ -129,13 +150,13 @@ func (i *CyclonedxJSONCrafter) extractMainComponent(rawFile []byte) (*SBOMMainCo

// 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
stdName, err := remotename.NewRepository(strings.Split(component.Name, ":")[0])
ref, err := remotename.ParseReference(component.Name)
if err != nil {
return nil, fmt.Errorf("couldn't parse OCI image repository name: %w", err)
}

return &SBOMMainComponentInfo{
name: stdName.String(),
name: ref.Context().String(),
kind: component.Type,
version: component.Version,
}, nil
Expand Down
Loading