diff --git a/pkg/attestation/crafter/materials/cyclonedxjson.go b/pkg/attestation/crafter/materials/cyclonedxjson.go index 17fbc38d7..a0364ffd7 100644 --- a/pkg/attestation/crafter/materials/cyclonedxjson.go +++ b/pkg/attestation/crafter/materials/cyclonedxjson.go @@ -251,8 +251,6 @@ func (i *CyclonedxJSONCrafter) extractMetadata(m *api.Attestation_Material, meta // 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 == "" { for _, prop := range component.Properties { @@ -265,33 +263,18 @@ func (i *CyclonedxJSONCrafter) extractMainComponent(m *api.Attestation_Material, } } - if component.Type != containerComponentKind { - mainComponent = &SBOMMainComponentInfo{ - name: component.Name, - kind: component.Type, - version: component.Version, - } - } else { + name := component.Name + if component.Type == containerComponentKind { // 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, - } + name = ref.Context().String() } - // 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, - } + setMainComponent(m, name, component.Type, component.Version) return nil } diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index 6842efffd..320af3bba 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -84,14 +84,13 @@ var ( ErrBaseUploadAndCraft = errors.New("upload and craft error") ) -// SBOMMainComponentInfo is a struct that holds the main component information for a SBOM -type SBOMMainComponentInfo struct { - // name of the main component the SBOM is about - name string - // kind specifies the type of component - kind string - // version of the component - version string +// setMainComponent sets the main component information on an SBOM material. +// Shared by CycloneDX and SPDX crafters. +func setMainComponent(m *api.Attestation_Material, name, kind, version string) { + m.M.(*api.Attestation_Material_SbomArtifact).SbomArtifact.MainComponent = + &api.Attestation_Material_SBOMArtifact_MainComponent{ + Name: name, Kind: kind, Version: version, + } } type crafterCommon struct { diff --git a/pkg/attestation/crafter/materials/spdxjson.go b/pkg/attestation/crafter/materials/spdxjson.go index 885ad2cdd..4533ad009 100644 --- a/pkg/attestation/crafter/materials/spdxjson.go +++ b/pkg/attestation/crafter/materials/spdxjson.go @@ -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 { @@ -65,13 +66,87 @@ 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) + 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() + } + } + + setMainComponent(m, name, kind, 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 diff --git a/pkg/attestation/crafter/materials/spdxjson_test.go b/pkg/attestation/crafter/materials/spdxjson_test.go index 3b82cc763..fc8a9966e 100644 --- a/pkg/attestation/crafter/materials/spdxjson_test.go +++ b/pkg/attestation/crafter/materials/spdxjson_test.go @@ -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" @@ -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", @@ -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", @@ -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) @@ -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 { diff --git a/pkg/attestation/crafter/materials/testdata/sbom-spdx-container.json b/pkg/attestation/crafter/materials/testdata/sbom-spdx-container.json new file mode 100644 index 000000000..b45d5b162 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/sbom-spdx-container.json @@ -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" + } + ] +} diff --git a/pkg/attestation/crafter/materials/testdata/sbom-spdx-no-purpose.json b/pkg/attestation/crafter/materials/testdata/sbom-spdx-no-purpose.json new file mode 100644 index 000000000..04da6f855 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/sbom-spdx-no-purpose.json @@ -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" + } + ] +} diff --git a/pkg/attestation/crafter/materials/testdata/sbom-spdx-with-described-package.json b/pkg/attestation/crafter/materials/testdata/sbom-spdx-with-described-package.json new file mode 100644 index 000000000..a1488b911 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/sbom-spdx-with-described-package.json @@ -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" + } + ] +}