Skip to content

Commit 0797713

Browse files
committed
fix: add empty dep-graph when there are no dependencies
1 parent 42ac8de commit 0797713

File tree

6 files changed

+207
-32
lines changed

6 files changed

+207
-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
@@ -47,12 +47,14 @@ func (c client) ExportSBOM(inputDir string) (*scaplugin.Finding, error) {
4747
return nil, fmt.Errorf("failed to execute uv export: %w", err)
4848
}
4949

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

5455
return &scaplugin.Finding{
55-
Sbom: output,
56+
Sbom: output,
57+
Metadata: *metadata,
5658
FileExclusions: []string{
5759
// TODO(uv): uncomment when we are able to pass these to the CLI correctly. Currently the
5860
// `--exclude` flag does not accept paths, it only accepts file or dir names, which does not
@@ -65,22 +67,29 @@ func (c client) ExportSBOM(inputDir string) (*scaplugin.Finding, error) {
6567
}
6668

6769
// Verifies that the SBOM is valid JSON and has a root component.
68-
func validateSBOM(sbomData []byte) error {
70+
func validateSBOM(sbomData []byte) (*scaplugin.Metadata, error) {
6971
var sbom struct {
7072
Metadata struct {
71-
Component json.RawMessage `json:"component"`
73+
Component *struct {
74+
Name string `json:"name"`
75+
Version string `json:"version"`
76+
} `json:"component"`
7277
} `json:"metadata"`
7378
}
7479

7580
if err := json.Unmarshal(sbomData, &sbom); err != nil {
76-
return fmt.Errorf("failed to parse SBOM: %w", err)
81+
return nil, fmt.Errorf("failed to parse SBOM: %w", err)
7782
}
7883

79-
if len(sbom.Metadata.Component) == 0 {
80-
return fmt.Errorf("SBOM missing root component at metadata.component - uv project may be missing a root package")
84+
if sbom.Metadata.Component == nil {
85+
return nil, fmt.Errorf("SBOM missing root component at metadata.component - uv project may be missing a root package")
8186
}
8287

83-
return nil
88+
return &scaplugin.Metadata{
89+
PackageManager: "pip",
90+
Name: sbom.Metadata.Component.Name,
91+
Version: sbom.Metadata.Component.Version,
92+
}, nil
8493
}
8594

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

internal/uv/uvclient_test.go

Lines changed: 73 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,97 @@ 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 additional fields",
224+
sbom: `{
225+
"bomFormat": "CycloneDX",
226+
"specVersion": "1.5",
227+
"metadata": {
228+
"component": {
229+
"type": "application",
230+
"name": "complex-project",
231+
"version": "3.0.0",
232+
"bom-ref": "pkg:pypi/[email protected]",
233+
"description": "A complex project",
234+
"licenses": []
178235
}
179236
}
180237
}`,
238+
expectedName: "complex-project",
239+
expectedVersion: "3.0.0",
181240
},
182241
}
183242

184243
for _, tt := range tests {
185244
t.Run(tt.name, func(t *testing.T) {
186-
err := validateSBOM([]byte(tt.sbom))
245+
metadata, err := validateSBOM([]byte(tt.sbom))
187246
assert.NoError(t, err)
247+
require.NotNil(t, metadata)
248+
assert.Equal(t, "pip", metadata.PackageManager)
249+
assert.Equal(t, tt.expectedName, metadata.Name)
250+
assert.Equal(t, tt.expectedVersion, metadata.Version)
188251
})
189252
}
190253
}
@@ -215,16 +278,18 @@ func TestValidateSBOM_MissingComponent(t *testing.T) {
215278

216279
for _, tt := range tests {
217280
t.Run(tt.name, func(t *testing.T) {
218-
err := validateSBOM([]byte(tt.sbom))
281+
metadata, err := validateSBOM([]byte(tt.sbom))
219282
require.Error(t, err)
283+
assert.Nil(t, metadata)
220284
assert.Contains(t, err.Error(), "SBOM missing root component at metadata.component")
221285
})
222286
}
223287
}
224288

225289
func TestValidateSBOM_InvalidJSON(t *testing.T) {
226-
err := validateSBOM([]byte("invalid json"))
290+
metadata, err := validateSBOM([]byte("invalid json"))
227291
require.Error(t, err)
292+
assert.Nil(t, metadata)
228293
assert.Contains(t, err.Error(), "failed to parse SBOM")
229294
}
230295

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.PackageManager},
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/depgraph/sbom_resolution_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/golang/mock/gomock"
1111
"github.com/rs/zerolog"
12+
dg "github.com/snyk/cli-extension-dep-graph/internal/depgraph"
1213
"github.com/snyk/cli-extension-dep-graph/internal/mocks"
1314
"github.com/snyk/cli-extension-dep-graph/internal/uv"
1415
scaplugin "github.com/snyk/cli-extension-dep-graph/pkg/sca_plugin"
@@ -616,6 +617,68 @@ func Test_callback_SBOMResolution(t *testing.T) {
616617
})
617618
}
618619

620+
func Test_emptyDepGraph(t *testing.T) {
621+
testCases := []struct {
622+
name string
623+
finding *scaplugin.Finding
624+
wantErr bool
625+
validate func(t *testing.T, depGraph *dg.DepGraph)
626+
}{
627+
{
628+
name: "should create empty dep graph with correct package manager",
629+
finding: &scaplugin.Finding{
630+
Metadata: scaplugin.Metadata{
631+
PackageManager: "pip",
632+
Name: "my-project",
633+
Version: "1.0.0",
634+
},
635+
},
636+
wantErr: false,
637+
validate: func(t *testing.T, depGraph *dg.DepGraph) {
638+
require.NotNil(t, depGraph)
639+
assert.Equal(t, "pip", depGraph.PkgManager.Name)
640+
rootPkg := depGraph.GetRootPkg()
641+
require.NotNil(t, rootPkg)
642+
assert.Equal(t, "my-project", rootPkg.Info.Name)
643+
assert.Equal(t, "1.0.0", rootPkg.Info.Version)
644+
},
645+
},
646+
{
647+
name: "should handle empty version",
648+
finding: &scaplugin.Finding{
649+
Metadata: scaplugin.Metadata{
650+
PackageManager: "pip",
651+
Name: "test-package",
652+
Version: "",
653+
},
654+
},
655+
wantErr: false,
656+
validate: func(t *testing.T, depGraph *dg.DepGraph) {
657+
require.NotNil(t, depGraph)
658+
assert.Equal(t, "pip", depGraph.PkgManager.Name)
659+
rootPkg := depGraph.GetRootPkg()
660+
require.NotNil(t, rootPkg)
661+
assert.Equal(t, "test-package", rootPkg.Info.Name)
662+
assert.Equal(t, "", rootPkg.Info.Version)
663+
},
664+
},
665+
}
666+
667+
for _, tc := range testCases {
668+
t.Run(tc.name, func(t *testing.T) {
669+
depGraph, err := emptyDepGraph(tc.finding)
670+
if tc.wantErr {
671+
require.Error(t, err)
672+
} else {
673+
require.NoError(t, err)
674+
if tc.validate != nil {
675+
tc.validate(t, depGraph)
676+
}
677+
}
678+
})
679+
}
680+
}
681+
619682
func Test_getExclusionsFromFindings(t *testing.T) {
620683
testCases := []struct {
621684
name string

0 commit comments

Comments
 (0)