diff --git a/pkg/attestation/crafter/materials/spdxjson.go b/pkg/attestation/crafter/materials/spdxjson.go index 885ad2cdd..ae5c4b9f9 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,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) + 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 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" + } + ] +}