Skip to content

Commit d30d287

Browse files
waveywavesclaude
andcommitted
feat(materials): extract main component info from SPDX files
Follow the CycloneDX pattern to extract and populate MainComponent in SBOMArtifact for SPDX JSON materials. Uses spdxlib.GetDescribedPackageIDs to find the described package, then extracts name, version, and kind (PrimaryPackagePurpose). Container names are standardized via go-containerregistry. Gracefully skips when no described package is found. Fixes #2580 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>
1 parent 7542585 commit d30d287

4 files changed

Lines changed: 223 additions & 19 deletions

File tree

pkg/attestation/crafter/materials/spdxjson.go

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ import (
2424
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2525
api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2626
"github.com/chainloop-dev/chainloop/pkg/casclient"
27+
remotename "github.com/google/go-containerregistry/pkg/name"
28+
"github.com/rs/zerolog"
2729
"github.com/spdx/tools-golang/json"
2830
"github.com/spdx/tools-golang/spdx"
29-
30-
"github.com/rs/zerolog"
31+
"github.com/spdx/tools-golang/spdxlib"
3132
)
3233

3334
type SPDXJSONCrafter struct {
@@ -65,9 +66,70 @@ func (i *SPDXJSONCrafter) Craft(ctx context.Context, filePath string) (*api.Atte
6566
return nil, err
6667
}
6768

69+
res := m
70+
res.M = &api.Attestation_Material_SbomArtifact{
71+
SbomArtifact: &api.Attestation_Material_SBOMArtifact{
72+
Artifact: m.GetArtifact(),
73+
},
74+
}
75+
76+
// Extract main component information from SPDX document
77+
if err := i.extractMainComponent(m, doc); err != nil {
78+
i.logger.Debug().Err(err).Msg("error extracting main component from spdx sbom, skipping...")
79+
}
80+
6881
i.injectAnnotations(m, doc)
6982

70-
return m, nil
83+
return res, nil
84+
}
85+
86+
// extractMainComponent inspects the SPDX document and extracts the main component if any
87+
func (i *SPDXJSONCrafter) extractMainComponent(m *api.Attestation_Material, doc *spdx.Document) error {
88+
describedIDs, err := spdxlib.GetDescribedPackageIDs(doc)
89+
if err != nil {
90+
return fmt.Errorf("couldn't get described packages: %w", err)
91+
}
92+
93+
if len(describedIDs) == 0 {
94+
return fmt.Errorf("no described packages found")
95+
}
96+
97+
// Use the first described package
98+
targetID := describedIDs[0]
99+
100+
// Find the package by ID
101+
var describedPkg *spdx.Package
102+
for _, pkg := range doc.Packages {
103+
if pkg.PackageSPDXIdentifier == targetID {
104+
describedPkg = pkg
105+
break
106+
}
107+
}
108+
109+
if describedPkg == nil {
110+
return fmt.Errorf("described package %q not found in packages list", targetID)
111+
}
112+
113+
name := describedPkg.PackageName
114+
version := describedPkg.PackageVersion
115+
kind := strings.ToLower(describedPkg.PrimaryPackagePurpose)
116+
117+
// For container packages, standardize the name via go-containerregistry
118+
if kind == containerComponentKind {
119+
ref, err := remotename.ParseReference(name)
120+
if err != nil {
121+
return fmt.Errorf("couldn't parse OCI image repository name: %w", err)
122+
}
123+
name = ref.Context().String()
124+
}
125+
126+
m.M.(*api.Attestation_Material_SbomArtifact).SbomArtifact.MainComponent = &api.Attestation_Material_SBOMArtifact_MainComponent{
127+
Name: name,
128+
Kind: kind,
129+
Version: version,
130+
}
131+
132+
return nil
71133
}
72134

73135
func (i *SPDXJSONCrafter) injectAnnotations(m *api.Attestation_Material, doc *spdx.Document) {

pkg/attestation/crafter/materials/spdxjson_test.go

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import (
2121
"testing"
2222

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

6766
func TestSPDXJSONCraft(t *testing.T) {
6867
testCases := []struct {
69-
name string
70-
filePath string
71-
wantErr string
72-
wantDigest string
73-
wantFilename string
74-
annotations map[string]string
75-
absentAnnotations []string
68+
name string
69+
filePath string
70+
wantErr string
71+
wantDigest string
72+
wantFilename string
73+
wantMainComponent string
74+
wantMainComponentKind string
75+
wantMainComponentVersion string
76+
wantNoMainComponent bool
77+
annotations map[string]string
78+
absentAnnotations []string
7679
}{
7780
{
7881
name: "invalid sbom format",
@@ -90,10 +93,11 @@ func TestSPDXJSONCraft(t *testing.T) {
9093
wantErr: "unexpected material type",
9194
},
9295
{
93-
name: "valid artifact type",
94-
filePath: "./testdata/sbom-spdx.json",
95-
wantDigest: "sha256:fe2636fb6c698a29a315278b762b2000efd5959afe776ee4f79f1ed523365a33",
96-
wantFilename: "sbom-spdx.json",
96+
name: "valid artifact type (no described package)",
97+
filePath: "./testdata/sbom-spdx.json",
98+
wantDigest: "sha256:fe2636fb6c698a29a315278b762b2000efd5959afe776ee4f79f1ed523365a33",
99+
wantFilename: "sbom-spdx.json",
100+
wantNoMainComponent: true,
97101
annotations: map[string]string{
98102
"chainloop.material.tool.name": "syft",
99103
"chainloop.material.tool.version": "0.73.0",
@@ -132,6 +136,34 @@ func TestSPDXJSONCraft(t *testing.T) {
132136
"chainloop.material.tools": `["spdxgen@1.0.0","scanner@2.1.5"]`,
133137
},
134138
},
139+
{
140+
name: "with described application package",
141+
filePath: "./testdata/sbom-spdx-with-described-package.json",
142+
wantDigest: "sha256:c6aaa874345f9d309f1b7a3e1cdb00817c91326fcc8ede9507dce882b0efdf16",
143+
wantFilename: "sbom-spdx-with-described-package.json",
144+
wantMainComponent: "my-app",
145+
wantMainComponentKind: "application",
146+
wantMainComponentVersion: "1.2.3",
147+
annotations: map[string]string{
148+
"chainloop.material.tool.name": "syft",
149+
"chainloop.material.tool.version": "0.100.0",
150+
"chainloop.material.tools": `["syft@0.100.0"]`,
151+
},
152+
},
153+
{
154+
name: "with described container package",
155+
filePath: "./testdata/sbom-spdx-container.json",
156+
wantDigest: "sha256:47df762774170904a7ab6bf0c565a2c525b60c3f92f9484744f9aafc631ce307",
157+
wantFilename: "sbom-spdx-container.json",
158+
wantMainComponent: "ghcr.io/chainloop-dev/chainloop/control-plane",
159+
wantMainComponentKind: "container",
160+
wantMainComponentVersion: "sha256:abcdef1234567890",
161+
annotations: map[string]string{
162+
"chainloop.material.tool.name": "trivy",
163+
"chainloop.material.tool.version": "0.50.0",
164+
"chainloop.material.tools": `["trivy@0.50.0"]`,
165+
},
166+
},
135167
}
136168

137169
assert := assert.New(t)
@@ -166,10 +198,22 @@ func TestSPDXJSONCraft(t *testing.T) {
166198
assert.Equal(contractAPI.CraftingSchema_Material_SBOM_SPDX_JSON.String(), got.MaterialType.String())
167199
assert.True(got.UploadedToCas)
168200

169-
// The result includes the digest reference
170-
assert.Equal(got.GetArtifact(), &attestationApi.Attestation_Material_Artifact{
171-
Id: "test", Digest: tc.wantDigest, Name: tc.wantFilename,
172-
})
201+
// The result wraps the artifact in SbomArtifact
202+
sbomArtifact := got.GetSbomArtifact()
203+
require.NotNil(t, sbomArtifact)
204+
assert.Equal(tc.wantDigest, sbomArtifact.Artifact.Digest)
205+
assert.Equal(tc.wantFilename, sbomArtifact.Artifact.Name)
206+
assert.Equal("test", sbomArtifact.Artifact.Id)
207+
208+
// Validate main component extraction
209+
if tc.wantNoMainComponent {
210+
assert.Nil(sbomArtifact.MainComponent)
211+
} else if tc.wantMainComponent != "" {
212+
require.NotNil(t, sbomArtifact.MainComponent)
213+
assert.Equal(tc.wantMainComponent, sbomArtifact.MainComponent.Name)
214+
assert.Equal(tc.wantMainComponentKind, sbomArtifact.MainComponent.Kind)
215+
assert.Equal(tc.wantMainComponentVersion, sbomArtifact.MainComponent.Version)
216+
}
173217

174218
// Validate annotations if specified
175219
if tc.annotations != nil {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"spdxVersion": "SPDX-2.3",
3+
"dataLicense": "CC0-1.0",
4+
"SPDXID": "SPDXRef-DOCUMENT",
5+
"name": "ghcr.io/chainloop-dev/chainloop/control-plane",
6+
"documentNamespace": "https://example.com/test/control-plane-5678",
7+
"creationInfo": {
8+
"licenseListVersion": "3.20",
9+
"creators": [
10+
"Organization: Example, Inc",
11+
"Tool: trivy-0.50.0"
12+
],
13+
"created": "2024-01-15T10:00:00Z"
14+
},
15+
"packages": [
16+
{
17+
"name": "ghcr.io/chainloop-dev/chainloop/control-plane:v0.55.0",
18+
"SPDXID": "SPDXRef-Package-control-plane",
19+
"versionInfo": "sha256:abcdef1234567890",
20+
"downloadLocation": "NOASSERTION",
21+
"primaryPackagePurpose": "CONTAINER",
22+
"licenseConcluded": "NOASSERTION",
23+
"licenseDeclared": "NOASSERTION",
24+
"copyrightText": "NOASSERTION",
25+
"supplier": "Organization: Chainloop"
26+
},
27+
{
28+
"name": "libc",
29+
"SPDXID": "SPDXRef-Package-libc",
30+
"versionInfo": "2.31",
31+
"downloadLocation": "NOASSERTION",
32+
"licenseConcluded": "GPL-2.0-only",
33+
"licenseDeclared": "GPL-2.0-only",
34+
"copyrightText": "NOASSERTION"
35+
}
36+
],
37+
"relationships": [
38+
{
39+
"spdxElementId": "SPDXRef-DOCUMENT",
40+
"relatedSpdxElement": "SPDXRef-Package-control-plane",
41+
"relationshipType": "DESCRIBES"
42+
},
43+
{
44+
"spdxElementId": "SPDXRef-Package-control-plane",
45+
"relatedSpdxElement": "SPDXRef-Package-libc",
46+
"relationshipType": "CONTAINS"
47+
}
48+
]
49+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"spdxVersion": "SPDX-2.3",
3+
"dataLicense": "CC0-1.0",
4+
"SPDXID": "SPDXRef-DOCUMENT",
5+
"name": "my-app",
6+
"documentNamespace": "https://example.com/test/my-app-1234",
7+
"creationInfo": {
8+
"licenseListVersion": "3.20",
9+
"creators": [
10+
"Organization: Example, Inc",
11+
"Tool: syft-0.100.0"
12+
],
13+
"created": "2024-01-15T10:00:00Z"
14+
},
15+
"packages": [
16+
{
17+
"name": "my-app",
18+
"SPDXID": "SPDXRef-Package-my-app",
19+
"versionInfo": "1.2.3",
20+
"downloadLocation": "https://example.com/my-app",
21+
"primaryPackagePurpose": "APPLICATION",
22+
"licenseConcluded": "Apache-2.0",
23+
"licenseDeclared": "Apache-2.0",
24+
"copyrightText": "NOASSERTION",
25+
"supplier": "Organization: Example, Inc"
26+
},
27+
{
28+
"name": "dep-a",
29+
"SPDXID": "SPDXRef-Package-dep-a",
30+
"versionInfo": "0.1.0",
31+
"downloadLocation": "NOASSERTION",
32+
"licenseConcluded": "MIT",
33+
"licenseDeclared": "MIT",
34+
"copyrightText": "NOASSERTION"
35+
}
36+
],
37+
"relationships": [
38+
{
39+
"spdxElementId": "SPDXRef-DOCUMENT",
40+
"relatedSpdxElement": "SPDXRef-Package-my-app",
41+
"relationshipType": "DESCRIBES"
42+
},
43+
{
44+
"spdxElementId": "SPDXRef-Package-my-app",
45+
"relatedSpdxElement": "SPDXRef-Package-dep-a",
46+
"relationshipType": "DEPENDS_ON"
47+
}
48+
]
49+
}

0 commit comments

Comments
 (0)