Skip to content

Commit 143b790

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 143b790

File tree

5 files changed

+288
-20
lines changed

5 files changed

+288
-20
lines changed

pkg/attestation/crafter/materials/spdxjson.go

Lines changed: 84 additions & 4 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,13 +66,92 @@ 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+
// It uses the first described package (via DESCRIBES relationship). If multiple described
88+
// packages exist, only the first is used and a warning is logged.
89+
// NOTE: SPDX PrimaryPackagePurpose values (APPLICATION, CONTAINER, FRAMEWORK, LIBRARY, etc.)
90+
// are lowercased for consistency with CycloneDX component types. The two specs have different
91+
// vocabularies so consumers should handle both sets of values.
92+
func (i *SPDXJSONCrafter) extractMainComponent(m *api.Attestation_Material, doc *spdx.Document) error {
93+
describedIDs, err := spdxlib.GetDescribedPackageIDs(doc)
94+
if err != nil {
95+
return fmt.Errorf("couldn't get described packages: %w", err)
96+
}
97+
98+
if len(describedIDs) == 0 {
99+
return fmt.Errorf("no described packages found")
100+
}
101+
102+
if len(describedIDs) > 1 {
103+
i.logger.Warn().Int("count", len(describedIDs)).Msg("multiple described packages found, using the first one")
104+
}
105+
106+
// Use the first described package
107+
targetID := describedIDs[0]
108+
109+
// Find the package by ID
110+
var describedPkg *spdx.Package
111+
for _, pkg := range doc.Packages {
112+
if pkg.PackageSPDXIdentifier == targetID {
113+
describedPkg = pkg
114+
break
115+
}
116+
}
117+
118+
if describedPkg == nil {
119+
return fmt.Errorf("described package %q not found in packages list", targetID)
120+
}
121+
122+
name := describedPkg.PackageName
123+
version := describedPkg.PackageVersion
124+
125+
// PrimaryPackagePurpose is optional in SPDX 2.3. If absent, skip main component extraction
126+
// since we cannot determine the component kind.
127+
kind := strings.ToLower(describedPkg.PrimaryPackagePurpose)
128+
if kind == "" {
129+
return fmt.Errorf("described package %q has no PrimaryPackagePurpose set", describedPkg.PackageName)
130+
}
131+
132+
// For container packages, standardize the name via go-containerregistry
133+
// to get the full repository name and strip any tag (matching CycloneDX behavior)
134+
if kind == containerComponentKind {
135+
ref, err := remotename.ParseReference(name)
136+
if err != nil {
137+
return fmt.Errorf("couldn't parse OCI image repository name: %w", err)
138+
}
139+
name = ref.Context().String()
140+
}
141+
142+
m.M.(*api.Attestation_Material_SbomArtifact).SbomArtifact.MainComponent = &api.Attestation_Material_SBOMArtifact_MainComponent{
143+
Name: name,
144+
Kind: kind,
145+
Version: version,
146+
}
147+
148+
return nil
71149
}
72150

73151
func (i *SPDXJSONCrafter) injectAnnotations(m *api.Attestation_Material, doc *spdx.Document) {
74-
m.Annotations = make(map[string]string)
152+
if m.Annotations == nil {
153+
m.Annotations = make(map[string]string)
154+
}
75155

76156
// Extract all tools from the creators array
77157
var tools []Tool

pkg/attestation/crafter/materials/spdxjson_test.go

Lines changed: 72 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,46 @@ 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+
},
167+
{
168+
name: "described package without PrimaryPackagePurpose (optional in SPDX 2.3)",
169+
filePath: "./testdata/sbom-spdx-no-purpose.json",
170+
wantDigest: "sha256:140b55bcbdd447fee1c86d50d8459b05159bebcd80a8a8da4ea6475eeab2f487",
171+
wantFilename: "sbom-spdx-no-purpose.json",
172+
wantNoMainComponent: true,
173+
annotations: map[string]string{
174+
"chainloop.material.tool.name": "syft",
175+
"chainloop.material.tool.version": "0.100.0",
176+
"chainloop.material.tools": `["syft@0.100.0"]`,
177+
},
178+
},
135179
}
136180

137181
assert := assert.New(t)
@@ -166,10 +210,22 @@ func TestSPDXJSONCraft(t *testing.T) {
166210
assert.Equal(contractAPI.CraftingSchema_Material_SBOM_SPDX_JSON.String(), got.MaterialType.String())
167211
assert.True(got.UploadedToCas)
168212

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-
})
213+
// The result wraps the artifact in SbomArtifact
214+
sbomArtifact := got.GetSbomArtifact()
215+
require.NotNil(t, sbomArtifact)
216+
assert.Equal(tc.wantDigest, sbomArtifact.Artifact.Digest)
217+
assert.Equal(tc.wantFilename, sbomArtifact.Artifact.Name)
218+
assert.Equal("test", sbomArtifact.Artifact.Id)
219+
220+
// Validate main component extraction
221+
if tc.wantNoMainComponent {
222+
assert.Nil(sbomArtifact.MainComponent)
223+
} else if tc.wantMainComponent != "" {
224+
require.NotNil(t, sbomArtifact.MainComponent)
225+
assert.Equal(tc.wantMainComponent, sbomArtifact.MainComponent.Name)
226+
assert.Equal(tc.wantMainComponentKind, sbomArtifact.MainComponent.Kind)
227+
assert.Equal(tc.wantMainComponentVersion, sbomArtifact.MainComponent.Version)
228+
}
173229

174230
// Validate annotations if specified
175231
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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"spdxVersion": "SPDX-2.3",
3+
"dataLicense": "CC0-1.0",
4+
"SPDXID": "SPDXRef-DOCUMENT",
5+
"name": "my-lib",
6+
"documentNamespace": "https://example.com/test/my-lib-9999",
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-lib",
18+
"SPDXID": "SPDXRef-Package-my-lib",
19+
"versionInfo": "2.0.0",
20+
"downloadLocation": "https://example.com/my-lib",
21+
"licenseConcluded": "MIT",
22+
"licenseDeclared": "MIT",
23+
"copyrightText": "NOASSERTION",
24+
"supplier": "Organization: Example, Inc"
25+
}
26+
],
27+
"relationships": [
28+
{
29+
"spdxElementId": "SPDXRef-DOCUMENT",
30+
"relatedSpdxElement": "SPDXRef-Package-my-lib",
31+
"relationshipType": "DESCRIBES"
32+
}
33+
]
34+
}
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)