Skip to content
Open
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
87 changes: 83 additions & 4 deletions pkg/attestation/crafter/materials/spdxjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ import (
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"
remotename "github.com/google/go-containerregistry/pkg/name"
"github.com/rs/zerolog"
"github.com/spdx/tools-golang/json"
"github.com/spdx/tools-golang/spdx"

"github.com/rs/zerolog"
"github.com/spdx/tools-golang/spdxlib"
)

type SPDXJSONCrafter struct {
Expand Down Expand Up @@ -65,13 +66,91 @@ func (i *SPDXJSONCrafter) Craft(ctx context.Context, filePath string) (*api.Atte
return nil, err
}

res := m
res.M = &api.Attestation_Material_SbomArtifact{
SbomArtifact: &api.Attestation_Material_SBOMArtifact{
Artifact: m.GetArtifact(),
},
}

// Extract main component information from SPDX document
if err := i.extractMainComponent(m, doc); err != nil {
i.logger.Debug().Err(err).Msg("error extracting main component from spdx sbom, skipping...")
}

i.injectAnnotations(m, doc)

return m, nil
return res, nil
}

// extractMainComponent inspects the SPDX document and extracts the main component if any.
// It uses the first described package (via DESCRIBES relationship). If multiple described
// packages exist, only the first is used and a warning is logged.
// NOTE: SPDX PrimaryPackagePurpose values (APPLICATION, CONTAINER, FRAMEWORK, LIBRARY, etc.)
// are lowercased for consistency with CycloneDX component types. The two specs have different
// vocabularies so consumers should handle both sets of values.
func (i *SPDXJSONCrafter) extractMainComponent(m *api.Attestation_Material, doc *spdx.Document) error {
describedIDs, err := spdxlib.GetDescribedPackageIDs(doc)
if err != nil {
return fmt.Errorf("couldn't get described packages: %w", err)
}

if len(describedIDs) == 0 {
return fmt.Errorf("no described packages found")
}

if len(describedIDs) > 1 {
i.logger.Warn().Int("count", len(describedIDs)).Msg("multiple described packages found, using the first one")
}

// Use the first described package
targetID := describedIDs[0]

// Find the package by ID
var describedPkg *spdx.Package
for _, pkg := range doc.Packages {
if pkg.PackageSPDXIdentifier == targetID {
describedPkg = pkg
break
}
}

if describedPkg == nil {
return fmt.Errorf("described package %q not found in packages list", targetID)
}

name := describedPkg.PackageName
version := describedPkg.PackageVersion

// PrimaryPackagePurpose is optional in SPDX 2.3. Best effort: return name
// and version even if kind is unknown.
kind := strings.ToLower(describedPkg.PrimaryPackagePurpose)

// For container packages, standardize the name via go-containerregistry
// to get the full repository name and strip any tag (matching CycloneDX behavior).
// If parsing fails (e.g. missing registry credentials), continue with the original name.
if kind == containerComponentKind {
ref, err := remotename.ParseReference(name)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Here it's likely that chainloop doesn't have the credentials for this query in most cases. Instead of returning, I'd just continue and return the existing values.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense — chainloop likely won't have registry credentials in most cases. Now logs at debug level and continues with the original name instead of returning an error.

if err != nil {
i.logger.Debug().Err(err).Str("name", name).Msg("couldn't parse OCI image reference, using original name")
} else {
name = ref.Context().String()
}
}

m.M.(*api.Attestation_Material_SbomArtifact).SbomArtifact.MainComponent = &api.Attestation_Material_SBOMArtifact_MainComponent{
Name: name,
Kind: kind,
Version: version,
}

return nil
}

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

// Extract all tools from the creators array
var tools []Tool
Expand Down
89 changes: 73 additions & 16 deletions pkg/attestation/crafter/materials/spdxjson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import (
"testing"

contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
attestationApi "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials"
"github.com/chainloop-dev/chainloop/pkg/casclient"
mUploader "github.com/chainloop-dev/chainloop/pkg/casclient/mocks"
Expand Down Expand Up @@ -66,13 +65,17 @@ func TestNewSPDXJSONCrafter(t *testing.T) {

func TestSPDXJSONCraft(t *testing.T) {
testCases := []struct {
name string
filePath string
wantErr string
wantDigest string
wantFilename string
annotations map[string]string
absentAnnotations []string
name string
filePath string
wantErr string
wantDigest string
wantFilename string
wantMainComponent string
wantMainComponentKind string
wantMainComponentVersion string
wantNoMainComponent bool
annotations map[string]string
absentAnnotations []string
}{
{
name: "invalid sbom format",
Expand All @@ -90,10 +93,11 @@ func TestSPDXJSONCraft(t *testing.T) {
wantErr: "unexpected material type",
},
{
name: "valid artifact type",
filePath: "./testdata/sbom-spdx.json",
wantDigest: "sha256:fe2636fb6c698a29a315278b762b2000efd5959afe776ee4f79f1ed523365a33",
wantFilename: "sbom-spdx.json",
name: "valid artifact type (no described package)",
filePath: "./testdata/sbom-spdx.json",
wantDigest: "sha256:fe2636fb6c698a29a315278b762b2000efd5959afe776ee4f79f1ed523365a33",
wantFilename: "sbom-spdx.json",
wantNoMainComponent: true,
annotations: map[string]string{
"chainloop.material.tool.name": "syft",
"chainloop.material.tool.version": "0.73.0",
Expand Down Expand Up @@ -132,6 +136,48 @@ func TestSPDXJSONCraft(t *testing.T) {
"chainloop.material.tools": `["spdxgen@1.0.0","scanner@2.1.5"]`,
},
},
{
name: "with described application package",
filePath: "./testdata/sbom-spdx-with-described-package.json",
wantDigest: "sha256:c6aaa874345f9d309f1b7a3e1cdb00817c91326fcc8ede9507dce882b0efdf16",
wantFilename: "sbom-spdx-with-described-package.json",
wantMainComponent: "my-app",
wantMainComponentKind: "application",
wantMainComponentVersion: "1.2.3",
annotations: map[string]string{
"chainloop.material.tool.name": "syft",
"chainloop.material.tool.version": "0.100.0",
"chainloop.material.tools": `["syft@0.100.0"]`,
},
},
{
name: "with described container package",
filePath: "./testdata/sbom-spdx-container.json",
wantDigest: "sha256:47df762774170904a7ab6bf0c565a2c525b60c3f92f9484744f9aafc631ce307",
wantFilename: "sbom-spdx-container.json",
wantMainComponent: "ghcr.io/chainloop-dev/chainloop/control-plane",
wantMainComponentKind: "container",
wantMainComponentVersion: "sha256:abcdef1234567890",
annotations: map[string]string{
"chainloop.material.tool.name": "trivy",
"chainloop.material.tool.version": "0.50.0",
"chainloop.material.tools": `["trivy@0.50.0"]`,
},
},
{
name: "described package without PrimaryPackagePurpose returns best-effort name and version",
filePath: "./testdata/sbom-spdx-no-purpose.json",
wantDigest: "sha256:140b55bcbdd447fee1c86d50d8459b05159bebcd80a8a8da4ea6475eeab2f487",
wantFilename: "sbom-spdx-no-purpose.json",
wantMainComponent: "my-lib",
wantMainComponentKind: "",
wantMainComponentVersion: "2.0.0",
annotations: map[string]string{
"chainloop.material.tool.name": "syft",
"chainloop.material.tool.version": "0.100.0",
"chainloop.material.tools": `["syft@0.100.0"]`,
},
},
}

assert := assert.New(t)
Expand Down Expand Up @@ -166,10 +212,21 @@ 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
assert.Equal(got.GetArtifact(), &attestationApi.Attestation_Material_Artifact{
Id: "test", Digest: tc.wantDigest, Name: tc.wantFilename,
})
// The result wraps the artifact in SbomArtifact
sbomArtifact := got.GetSbomArtifact()
require.NotNil(t, sbomArtifact)
assert.Equal(tc.wantDigest, sbomArtifact.Artifact.Digest)
assert.Equal(tc.wantFilename, sbomArtifact.Artifact.Name)

// Validate main component extraction
if tc.wantNoMainComponent {
assert.Nil(sbomArtifact.MainComponent)
} else if tc.wantMainComponent != "" {
require.NotNil(t, sbomArtifact.MainComponent)
assert.Equal(tc.wantMainComponent, sbomArtifact.MainComponent.Name)
assert.Equal(tc.wantMainComponentKind, sbomArtifact.MainComponent.Kind)
assert.Equal(tc.wantMainComponentVersion, sbomArtifact.MainComponent.Version)
}

// Validate annotations if specified
if tc.annotations != nil {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "ghcr.io/chainloop-dev/chainloop/control-plane",
"documentNamespace": "https://example.com/test/control-plane-5678",
"creationInfo": {
"licenseListVersion": "3.20",
"creators": [
"Organization: Example, Inc",
"Tool: trivy-0.50.0"
],
"created": "2024-01-15T10:00:00Z"
},
"packages": [
{
"name": "ghcr.io/chainloop-dev/chainloop/control-plane:v0.55.0",
"SPDXID": "SPDXRef-Package-control-plane",
"versionInfo": "sha256:abcdef1234567890",
"downloadLocation": "NOASSERTION",
"primaryPackagePurpose": "CONTAINER",
"licenseConcluded": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"copyrightText": "NOASSERTION",
"supplier": "Organization: Chainloop"
},
{
"name": "libc",
"SPDXID": "SPDXRef-Package-libc",
"versionInfo": "2.31",
"downloadLocation": "NOASSERTION",
"licenseConcluded": "GPL-2.0-only",
"licenseDeclared": "GPL-2.0-only",
"copyrightText": "NOASSERTION"
}
],
"relationships": [
{
"spdxElementId": "SPDXRef-DOCUMENT",
"relatedSpdxElement": "SPDXRef-Package-control-plane",
"relationshipType": "DESCRIBES"
},
{
"spdxElementId": "SPDXRef-Package-control-plane",
"relatedSpdxElement": "SPDXRef-Package-libc",
"relationshipType": "CONTAINS"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "my-lib",
"documentNamespace": "https://example.com/test/my-lib-9999",
"creationInfo": {
"licenseListVersion": "3.20",
"creators": [
"Organization: Example, Inc",
"Tool: syft-0.100.0"
],
"created": "2024-01-15T10:00:00Z"
},
"packages": [
{
"name": "my-lib",
"SPDXID": "SPDXRef-Package-my-lib",
"versionInfo": "2.0.0",
"downloadLocation": "https://example.com/my-lib",
"licenseConcluded": "MIT",
"licenseDeclared": "MIT",
"copyrightText": "NOASSERTION",
"supplier": "Organization: Example, Inc"
}
],
"relationships": [
{
"spdxElementId": "SPDXRef-DOCUMENT",
"relatedSpdxElement": "SPDXRef-Package-my-lib",
"relationshipType": "DESCRIBES"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": "my-app",
"documentNamespace": "https://example.com/test/my-app-1234",
"creationInfo": {
"licenseListVersion": "3.20",
"creators": [
"Organization: Example, Inc",
"Tool: syft-0.100.0"
],
"created": "2024-01-15T10:00:00Z"
},
"packages": [
{
"name": "my-app",
"SPDXID": "SPDXRef-Package-my-app",
"versionInfo": "1.2.3",
"downloadLocation": "https://example.com/my-app",
"primaryPackagePurpose": "APPLICATION",
"licenseConcluded": "Apache-2.0",
"licenseDeclared": "Apache-2.0",
"copyrightText": "NOASSERTION",
"supplier": "Organization: Example, Inc"
},
{
"name": "dep-a",
"SPDXID": "SPDXRef-Package-dep-a",
"versionInfo": "0.1.0",
"downloadLocation": "NOASSERTION",
"licenseConcluded": "MIT",
"licenseDeclared": "MIT",
"copyrightText": "NOASSERTION"
}
],
"relationships": [
{
"spdxElementId": "SPDXRef-DOCUMENT",
"relatedSpdxElement": "SPDXRef-Package-my-app",
"relationshipType": "DESCRIBES"
},
{
"spdxElementId": "SPDXRef-Package-my-app",
"relatedSpdxElement": "SPDXRef-Package-dep-a",
"relationshipType": "DEPENDS_ON"
}
]
}
Loading