Skip to content

Commit 03a1e8e

Browse files
committed
fix: add empty dep-graph when there are no dependencies
1 parent 32a4775 commit 03a1e8e

File tree

5 files changed

+157
-32
lines changed

5 files changed

+157
-32
lines changed

internal/depgraph/builder.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const (
2222

2323
func NewBuilder(pkgManager *PkgManager, rootPkg *PkgInfo) (*Builder, error) {
2424
if pkgManager == nil {
25-
return nil, errors.New("cannot create builder without a package manager")
25+
return nil, errors.New("cannot create builder without a package manager") //nolint:wrapcheck // Code is copied from `dep-graph-go`
2626
}
2727

2828
if rootPkg == nil {

internal/uv/uvclient.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,14 @@ func (c client) ExportSBOM(inputDir string) (*scaplugin.Finding, error) {
4848
return nil, fmt.Errorf("failed to execute uv export: %w", err)
4949
}
5050

51-
if err := validateSBOM(output); err != nil {
51+
metadata, err := validateSBOM(output)
52+
if err != nil {
5253
return nil, err
5354
}
5455

5556
return &scaplugin.Finding{
56-
Sbom: output,
57+
Sbom: output,
58+
Metadata: *metadata,
5759
FileExclusions: []string{
5860
path.Join(inputDir, RequirementsTxtFileName),
5961
path.Join(inputDir, PyprojectTomlFileName),
@@ -63,22 +65,29 @@ func (c client) ExportSBOM(inputDir string) (*scaplugin.Finding, error) {
6365
}
6466

6567
// Verifies that the SBOM is valid JSON and has a root component.
66-
func validateSBOM(sbomData []byte) error {
68+
func validateSBOM(sbomData []byte) (*scaplugin.Metadata, error) {
6769
var sbom struct {
6870
Metadata struct {
69-
Component json.RawMessage `json:"component"`
71+
Component *struct {
72+
Name string `json:"name"`
73+
Version string `json:"version"`
74+
} `json:"component"`
7075
} `json:"metadata"`
7176
}
7277

7378
if err := json.Unmarshal(sbomData, &sbom); err != nil {
74-
return fmt.Errorf("failed to parse SBOM: %w", err)
79+
return nil, fmt.Errorf("failed to parse SBOM: %w", err)
7580
}
7681

77-
if len(sbom.Metadata.Component) == 0 {
78-
return fmt.Errorf("SBOM missing root component at metadata.component - uv project may be missing a root package")
82+
if sbom.Metadata.Component == nil {
83+
return nil, fmt.Errorf("SBOM missing root component at metadata.component - uv project may be missing a root package")
7984
}
8085

81-
return nil
86+
return &scaplugin.Metadata{
87+
PackageManager: "pip",
88+
Name: sbom.Metadata.Component.Name,
89+
Version: sbom.Metadata.Component.Version,
90+
}, nil
8291
}
8392

8493
func (c *client) ShouldExportSBOM(inputDir string, logger *zerolog.Logger) bool {

internal/uv/uvclient_test.go

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ func TestUVClient_ExportSBOM_Success(t *testing.T) {
2727
"component": {
2828
"type": "library",
2929
"bom-ref": "test-project-1",
30-
"name": "test-project"
30+
"name": "test-project",
31+
"version": "1.2.3"
3132
}
3233
}
3334
}`
@@ -45,7 +46,11 @@ func TestUVClient_ExportSBOM_Success(t *testing.T) {
4546
result, err := client.ExportSBOM("/test/dir")
4647

4748
assert.NoError(t, err)
49+
require.NotNil(t, result)
4850
assert.JSONEq(t, validSBOM, string(result.Sbom))
51+
assert.Equal(t, "pip", result.Metadata.PackageManager)
52+
assert.Equal(t, "test-project", result.Metadata.Name)
53+
assert.Equal(t, "1.2.3", result.Metadata.Version)
4954
}
5055

5156
func TestUVClient_ExportSBOM_Error(t *testing.T) {
@@ -152,39 +157,110 @@ func TestParseAndValidateVersion_UnparseableOutput(t *testing.T) {
152157

153158
func TestValidateSBOM_Success(t *testing.T) {
154159
tests := []struct {
155-
name string
156-
sbom string
160+
name string
161+
sbom string
162+
expectedName string
163+
expectedVersion string
157164
}{
158165
{
159-
name: "valid SBOM with component",
166+
name: "valid SBOM with full component",
160167
sbom: `{
161168
"bomFormat": "CycloneDX",
162169
"specVersion": "1.5",
163170
"metadata": {
164171
"component": {
165172
"type": "library",
166173
"name": "test-project",
174+
"version": "1.0.0",
167175
"bom-ref": "test-project-1"
168176
}
169177
}
170178
}`,
179+
expectedName: "test-project",
180+
expectedVersion: "1.0.0",
171181
},
172182
{
173183
name: "valid SBOM with minimal component",
174184
sbom: `{
175185
"metadata": {
176186
"component": {
177-
"name": "my-project"
187+
"name": "my-project",
188+
"version": "0.1.0"
189+
}
190+
}
191+
}`,
192+
expectedName: "my-project",
193+
expectedVersion: "0.1.0",
194+
},
195+
{
196+
name: "component without version",
197+
sbom: `{
198+
"bomFormat": "CycloneDX",
199+
"specVersion": "1.5",
200+
"metadata": {
201+
"component": {
202+
"name": "project-no-version"
203+
}
204+
}
205+
}`,
206+
expectedName: "project-no-version",
207+
expectedVersion: "",
208+
},
209+
{
210+
name: "component with empty version string",
211+
sbom: `{
212+
"metadata": {
213+
"component": {
214+
"name": "project-empty-version",
215+
"version": ""
216+
}
217+
}
218+
}`,
219+
expectedName: "project-empty-version",
220+
expectedVersion: "",
221+
},
222+
{
223+
name: "component with hyphenated name",
224+
sbom: `{
225+
"metadata": {
226+
"component": {
227+
"name": "my-complex-project-name",
228+
"version": "2.3.4-beta.1"
229+
}
230+
}
231+
}`,
232+
expectedName: "my-complex-project-name",
233+
expectedVersion: "2.3.4-beta.1",
234+
},
235+
{
236+
name: "component with additional fields",
237+
sbom: `{
238+
"bomFormat": "CycloneDX",
239+
"specVersion": "1.5",
240+
"metadata": {
241+
"component": {
242+
"type": "application",
243+
"name": "complex-project",
244+
"version": "3.0.0",
245+
"bom-ref": "pkg:pypi/[email protected]",
246+
"description": "A complex project",
247+
"licenses": []
178248
}
179249
}
180250
}`,
251+
expectedName: "complex-project",
252+
expectedVersion: "3.0.0",
181253
},
182254
}
183255

184256
for _, tt := range tests {
185257
t.Run(tt.name, func(t *testing.T) {
186-
err := validateSBOM([]byte(tt.sbom))
258+
metadata, err := validateSBOM([]byte(tt.sbom))
187259
assert.NoError(t, err)
260+
require.NotNil(t, metadata)
261+
assert.Equal(t, "pip", metadata.PackageManager)
262+
assert.Equal(t, tt.expectedName, metadata.Name)
263+
assert.Equal(t, tt.expectedVersion, metadata.Version)
188264
})
189265
}
190266
}
@@ -215,16 +291,18 @@ func TestValidateSBOM_MissingComponent(t *testing.T) {
215291

216292
for _, tt := range tests {
217293
t.Run(tt.name, func(t *testing.T) {
218-
err := validateSBOM([]byte(tt.sbom))
294+
metadata, err := validateSBOM([]byte(tt.sbom))
219295
require.Error(t, err)
296+
assert.Nil(t, metadata)
220297
assert.Contains(t, err.Error(), "SBOM missing root component at metadata.component")
221298
})
222299
}
223300
}
224301

225302
func TestValidateSBOM_InvalidJSON(t *testing.T) {
226-
err := validateSBOM([]byte("invalid json"))
303+
metadata, err := validateSBOM([]byte("invalid json"))
227304
require.Error(t, err)
305+
assert.Nil(t, metadata)
228306
assert.Contains(t, err.Error(), "failed to parse SBOM")
229307
}
230308

pkg/depgraph/sbom_resolution.go

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99

1010
"github.com/rs/zerolog"
11+
"github.com/snyk/cli-extension-dep-graph/internal/depgraph"
1112
"github.com/snyk/cli-extension-dep-graph/internal/snykclient"
1213
"github.com/snyk/cli-extension-dep-graph/internal/uv"
1314
scaplugin "github.com/snyk/cli-extension-dep-graph/pkg/sca_plugin"
@@ -81,7 +82,7 @@ func handleSBOMResolutionDI(
8182
// Convert SBOMs to workflow.Data
8283
workflowData := []workflow.Data{}
8384
for _, f := range findings { // Could be parallelised in future
84-
data, err := sbomToWorkflowData(f, snykClient, logger, remoteRepoURL)
85+
data, err := sbomToWorkflowData(&f, snykClient, logger, remoteRepoURL)
8586
if err != nil {
8687
return nil, fmt.Errorf("error converting SBOM: %w", err)
8788
}
@@ -150,7 +151,7 @@ func executeLegacyWorkflow(
150151
return nil, fmt.Errorf("error handling legacy workflow: %w", err)
151152
}
152153

153-
func sbomToWorkflowData(finding scaplugin.Finding, snykClient *snykclient.SnykClient, logger *zerolog.Logger, remoteRepoURL string) ([]workflow.Data, error) {
154+
func sbomToWorkflowData(finding *scaplugin.Finding, snykClient *snykclient.SnykClient, logger *zerolog.Logger, remoteRepoURL string) ([]workflow.Data, error) {
154155
sbomReader := bytes.NewReader(finding.Sbom)
155156

156157
scans, warnings, err := snykClient.SBOMConvert(context.Background(), logger, sbomReader, remoteRepoURL)
@@ -166,11 +167,32 @@ func sbomToWorkflowData(finding scaplugin.Finding, snykClient *snykclient.SnykCl
166167
}
167168

168169
if len(depGraphsData) == 0 {
169-
return nil, fmt.Errorf("no dependency graphs found in SBOM conversion response")
170+
depGraph, err := emptyDepGraph(finding)
171+
if err != nil {
172+
return nil, fmt.Errorf("failed to create empty depgraph: %w", err)
173+
}
174+
175+
data, err := workflowDataFromDepGraph(depGraph, finding.NormalisedTargetFile, "")
176+
if err != nil {
177+
return nil, fmt.Errorf("failed to create workflow data: %w", err)
178+
}
179+
depGraphsData = append(depGraphsData, data)
170180
}
171181
return depGraphsData, nil
172182
}
173183

184+
func emptyDepGraph(finding *scaplugin.Finding) (*depgraph.DepGraph, error) {
185+
builder, err := depgraph.NewBuilder(
186+
&depgraph.PkgManager{Name: finding.Metadata.Name},
187+
&depgraph.PkgInfo{Name: finding.Metadata.Name, Version: finding.Metadata.Version},
188+
)
189+
if err != nil {
190+
return nil, fmt.Errorf("failed to build depgraph: %w", err)
191+
}
192+
depGraph := builder.Build()
193+
return depGraph, nil
194+
}
195+
174196
func extractDepGraphsFromScans(scans []*snykclient.ScanResult, targetFile string) ([]workflow.Data, error) {
175197
var depGraphList []workflow.Data
176198

@@ -180,20 +202,11 @@ func extractDepGraphsFromScans(scans []*snykclient.ScanResult, targetFile string
180202
if fact.Type != "depGraph" {
181203
continue
182204
}
183-
// Marshal the depgraph data to JSON bytes
184-
depGraphBytes, err := json.Marshal(fact.Data)
185-
if err != nil {
186-
return nil, fmt.Errorf("failed to marshal depgraph data: %w", err)
187-
}
188205

189206
// Create workflow data with the depgraph
190-
data := workflow.NewData(DataTypeID, contentTypeJSON, depGraphBytes)
191-
192-
data.SetMetaData(contentLocationKey, targetFile)
193-
data.SetMetaData(MetaKeyNormalisedTargetFile, targetFile)
194-
195-
if scan.Identity.Type != "" {
196-
data.SetMetaData(MetaKeyTargetFileFromPlugin, scan.Identity.Type)
207+
data, err := workflowDataFromDepGraph(fact.Data, targetFile, scan.Identity.Type)
208+
if err != nil {
209+
return nil, fmt.Errorf("failed to create workflow data: %w", err)
197210
}
198211

199212
depGraphList = append(depGraphList, data)
@@ -202,3 +215,21 @@ func extractDepGraphsFromScans(scans []*snykclient.ScanResult, targetFile string
202215

203216
return depGraphList, nil
204217
}
218+
219+
func workflowDataFromDepGraph(depGraph any, normalisedTargetFile, targetFileFromPlugin string) (workflow.Data, error) {
220+
depGraphBytes, err := json.Marshal(depGraph)
221+
if err != nil {
222+
return nil, fmt.Errorf("failed to marshal depgraph data: %w", err)
223+
}
224+
225+
data := workflow.NewData(DataTypeID, contentTypeJSON, depGraphBytes)
226+
227+
data.SetMetaData(contentLocationKey, normalisedTargetFile)
228+
data.SetMetaData(MetaKeyNormalisedTargetFile, normalisedTargetFile)
229+
230+
if targetFileFromPlugin != "" {
231+
data.SetMetaData(MetaKeyTargetFileFromPlugin, targetFileFromPlugin)
232+
}
233+
234+
return data, nil
235+
}

pkg/sca_plugin/interface.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@ import "github.com/rs/zerolog"
44

55
type Options struct{}
66

7+
type Metadata struct {
8+
PackageManager string
9+
Name string
10+
Version string
11+
}
12+
713
type Finding struct {
814
Sbom Sbom // The raw SBOM bytes
15+
Metadata Metadata // Information about the finding
916
FileExclusions []string // Paths for files that other plugins should ignore
1017
NormalisedTargetFile string // The target file name without any qualifiers, e.g. `uv.lock` (and not `dir/uv.lock`)
1118
}

0 commit comments

Comments
 (0)