Skip to content
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
4 changes: 4 additions & 0 deletions app/cli/cmd/attestation_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/chainloop-dev/chainloop/app/cli/cmd/output"
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
"github.com/chainloop-dev/chainloop/pkg/resourceloader"
)

Expand Down Expand Up @@ -194,6 +195,9 @@ func displayMaterialInfo(status *action.AttestationStatusMaterial, policyEvaluat
if len(status.Material.Annotations) > 0 {
mt.AppendRow(table.Row{"Annotations", "------"})
for _, a := range status.Material.Annotations {
if materials.IsLegacyAnnotation(a.Name) {
continue
}
value := a.Value
if value == "" {
value = NotSet
Expand Down
7 changes: 7 additions & 0 deletions app/cli/cmd/attestation_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (

"github.com/chainloop-dev/chainloop/app/cli/cmd/output"
"github.com/chainloop-dev/chainloop/app/cli/pkg/action"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
)

Expand Down Expand Up @@ -114,6 +115,9 @@ func attestationStatusTableOutput(status *action.AttestationStatusResult, w io.W
if len(status.Annotations) > 0 {
gt.AppendRow(table.Row{"Annotations", "------"})
for _, a := range status.Annotations {
if materials.IsLegacyAnnotation(a.Name) {
continue
}
value := a.Value
if value == "" {
value = NotSet
Expand Down Expand Up @@ -220,6 +224,9 @@ func materialsTable(status *action.AttestationStatusResult, w io.Writer, full bo
if len(m.Annotations) > 0 {
mt.AppendRow(table.Row{"Annotations", "------"})
for _, a := range m.Annotations {
if materials.IsLegacyAnnotation(a.Name) {
continue
}
value := a.Value
if value == "" {
value = NotSet
Expand Down
30 changes: 24 additions & 6 deletions pkg/attestation/crafter/materials/cyclonedxjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,19 +166,37 @@ func (i *CyclonedxJSONCrafter) extractMetadata(m *api.Attestation_Material, meta
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
// Extract all tools and set annotations
var tools []Tool
Copy link
Member

Choose a reason for hiding this comment

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

so even if we have one tool, it will use also the new annotation correct?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Correct, example:

WRN API contacted in insecure mode
INF uploading sbom.cyclonedx-1.5.json - sha256:5ca3508f02893b0419b266927f66c7b9dd8b11dbea7faf7cdb9169df8f69d8e3
INF material added to attestation
┌─────────────┬─────────────────────────────────────────────────────────────────────────┐
│ Name        │ skynet-sbom                                                             │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Type        │ SBOM_CYCLONEDX_JSON                                                     │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Required    │ No                                                                      │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Value       │ sbom.cyclonedx-1.5.json                                                 │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Digest      │ sha256:5ca3508f02893b0419b266927f66c7b9dd8b11dbea7faf7cdb9169df8f69d8e3 │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Annotations │ ------                                                                  │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│             │ chainloop.material.tools: ["syft@0.101.1"]                              │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│             │ chainloop.material.tool.name: syft                                      │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│             │ chainloop.material.tool.version: 0.101.1                                │
└─────────────┴─────────────────────────────────────────────────────────────────────────┘

Copy link
Member

Choose a reason for hiding this comment

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

should we hide the deprecated method from the output?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated, hidden from att add and att status

# add
┌─────────────┬─────────────────────────────────────────────────────────────────────────┐
│ Name        │ skynet-sbom                                                             │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Type        │ SBOM_CYCLONEDX_JSON                                                     │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Required    │ No                                                                      │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Value       │ sbom.cyclonedx-1.5.json                                                 │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Digest      │ sha256:5ca3508f02893b0419b266927f66c7b9dd8b11dbea7faf7cdb9169df8f69d8e3 │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│ Annotations │ ------                                                                  │
├─────────────┼─────────────────────────────────────────────────────────────────────────┤
│             │ chainloop.material.tools: ["syft@0.101.1"]                              │
└─────────────┴─────────────────────────────────────────────────────────────────────────┘
# status
┌───────────────────────────┬──────────────────────────────────────┐
│ Initialized At            │ 27 Oct 25 13:42 UTC                  │
├───────────────────────────┼──────────────────────────────────────┤
│ Attestation ID            │ a3d4ffcc-68f5-4d2e-b3b0-b96cf65922a4 │
│ Organization              │ myorg                                │
│ Name                      │ policyatttest                        │
│ Project                   │ myproject                            │
│ Version                   │ v1.49.0 (prerelease)                 │
│ Contract                  │ contractinvpoll (revision 1)         │
│ Policy violation strategy │ ADVISORY                             │
└───────────────────────────┴──────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Materials                                                │
├─────────────┬────────────────────────────────────────────┤
│ Name        │ skynet-sbom                                │
│ Type        │ SBOM_CYCLONEDX_JSON                        │
│ Set         │ Yes                                        │
│ Required    │ No                                         │
│ Annotations │ ------                                     │
│             │ chainloop.material.tools: ["syft@0.101.1"] │
└─────────────┴────────────────────────────────────────────┘

for _, tool := range meta.Tools {
tools = append(tools, Tool{Name: tool.Name, Version: tool.Version})
}
SetToolsAnnotation(m, tools)

// 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
}

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
// Extract all tools and set annotations
var tools []Tool
for _, tool := range meta.Tools.Components {
tools = append(tools, Tool{Name: tool.Name, Version: tool.Version})
}
SetToolsAnnotation(m, tools)

// 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
}

default:
i.logger.Debug().Msg("unknown metadata version")
}
Expand Down
14 changes: 14 additions & 0 deletions pkg/attestation/crafter/materials/cyclonedxjson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,20 @@ func TestCyclonedxJSONCraft(t *testing.T) {
"chainloop.material.sbom.vulnerabilities_report": "true",
},
},
{
name: "1.5 version with multiple tools",
filePath: "./testdata/sbom.cyclonedx-1.5-multiple-tools.json",
wantDigest: "sha256:56f82c99fb4740f952296705ceb2ee0c5c3c6a3309b35373d542d58878d65cd3",
wantFilename: "sbom.cyclonedx-1.5-multiple-tools.json",
wantMainComponent: "test-app",
wantMainComponentKind: "application",
wantMainComponentVersion: "1.0.0",
annotations: map[string]string{
"chainloop.material.tool.name": "Hub",
"chainloop.material.tool.version": "2025.4.2",
"chainloop.material.tools": `["Hub@2025.4.2","cyclonedx-core-java@5.0.5"]`,
},
},
}

schema := &contractAPI.CraftingSchema_Material{
Expand Down
35 changes: 35 additions & 0 deletions pkg/attestation/crafter/materials/materials.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package materials

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -36,6 +37,40 @@ import (

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

// IsLegacyAnnotation returns true if the annotation key is a legacy annotation
func IsLegacyAnnotation(key string) bool {
return key == AnnotationToolNameKey || key == AnnotationToolVersionKey
}

// Tool represents a tool with name and version
type Tool struct {
Name string
Version string
}

// SetToolsAnnotations sets the tools annotation as a JSON array in "name@version" format
func SetToolsAnnotation(m *api.Attestation_Material, tools []Tool) {
if len(tools) == 0 {
return
}

// Build array of "name@version" strings
toolStrings := make([]string, 0, len(tools))
for _, tool := range tools {
toolStr := tool.Name
if tool.Version != "" {
toolStr = fmt.Sprintf("%s@%s", tool.Name, tool.Version)
}
toolStrings = append(toolStrings, toolStr)
}

// Marshal to JSON array
if toolsJSON, err := json.Marshal(toolStrings); err == nil {
m.Annotations[AnnotationToolsKey] = string(toolsJSON)
}
}

var (
// ErrInvalidMaterialType is returned when the provided material type
Expand Down
24 changes: 16 additions & 8 deletions pkg/attestation/crafter/materials/spdxjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,27 @@ func (i *SPDXJSONCrafter) Craft(ctx context.Context, filePath string) (*api.Atte
}

func (i *SPDXJSONCrafter) injectAnnotations(m *api.Attestation_Material, doc *spdx.Document) {
m.Annotations = make(map[string]string)

// Extract all tools from the creators array
var tools []Tool
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]
name, version := c.Creator, ""
if parts := strings.SplitN(c.Creator, "-", 2); len(parts) == 2 {
name, version = parts[0], parts[1]
}
break
tools = append(tools, Tool{Name: name, Version: version})
}
}

SetToolsAnnotation(m, tools)

// 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
}
}
42 changes: 35 additions & 7 deletions pkg/attestation/crafter/materials/spdxjson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,12 @@ func TestNewSPDXJSONCrafter(t *testing.T) {

func TestSPDXJSONCraft(t *testing.T) {
testCases := []struct {
name string
filePath string
wantErr string
name string
filePath string
wantErr string
wantDigest string
wantFilename string
annotations map[string]string
}{
{
name: "invalid sbom format",
Expand All @@ -86,8 +89,26 @@ func TestSPDXJSONCraft(t *testing.T) {
wantErr: "unexpected material type",
},
{
name: "valid artifact type",
filePath: "./testdata/sbom-spdx.json",
name: "valid artifact type",
filePath: "./testdata/sbom-spdx.json",
wantDigest: "sha256:fe2636fb6c698a29a315278b762b2000efd5959afe776ee4f79f1ed523365a33",
wantFilename: "sbom-spdx.json",
annotations: map[string]string{
"chainloop.material.tool.name": "syft",
Copy link
Member

Choose a reason for hiding this comment

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

yes, this one, thanks

"chainloop.material.tool.version": "0.73.0",
"chainloop.material.tools": `["syft@0.73.0"]`,
},
},
{
name: "multiple tools",
filePath: "./testdata/sbom-spdx-multiple-tools.json",
wantDigest: "sha256:c1a61566c7c0224ac02ad9cd21d90234e5a71de26971e33df2205c1a2eb319fc",
wantFilename: "sbom-spdx-multiple-tools.json",
annotations: map[string]string{
"chainloop.material.tool.name": "spdxgen",
"chainloop.material.tool.version": "1.0.0",
"chainloop.material.tools": `["spdxgen@1.0.0","scanner@2.1.5"]`,
},
},
}

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

// // The result includes the digest reference
// The result includes the digest reference
assert.Equal(got.GetArtifact(), &attestationApi.Attestation_Material_Artifact{
Id: "test", Digest: "sha256:fe2636fb6c698a29a315278b762b2000efd5959afe776ee4f79f1ed523365a33", Name: "sbom-spdx.json",
Id: "test", Digest: tc.wantDigest, Name: tc.wantFilename,
})

// Validate annotations if specified
if tc.annotations != nil {
for k, v := range tc.annotations {
assert.Equal(v, got.Annotations[k])
}
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "test-multiple-tools",
"documentNamespace": "https://example.com/test/multiple-tools",
"creationInfo": {
"licenseListVersion": "3.20",
"creators": [
"Organization: Example Corp",
"Tool: spdxgen-1.0.0",
"Tool: scanner-2.1.5"
],
"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"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"$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": "2025.4.2"
},
{
"type": "library",
"author": "CycloneDX",
"name": "cyclonedx-core-java",
"version": "5.0.5"
}
]
},
"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"
}
]
}
Loading