Skip to content

Commit a6187de

Browse files
committed
fix: add empty dep-graph when there are no dependencies
1 parent ca49c69 commit a6187de

File tree

7 files changed

+329
-49
lines changed

7 files changed

+329
-49
lines changed

internal/depgraph/builder.go

Lines changed: 3 additions & 3 deletions
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 {
@@ -118,12 +118,12 @@ func (b *Builder) addNode(nodeID string, pkgInfo *PkgInfo) *Node {
118118
func (b *Builder) ConnectNodes(parentNodeID, childNodeID string) error {
119119
parentNode, ok := b.nodes[parentNodeID]
120120
if !ok {
121-
return fmt.Errorf("cound not find parent node %s", parentNodeID)
121+
return fmt.Errorf("could not find parent node %s", parentNodeID)
122122
}
123123

124124
childNode, ok := b.nodes[childNodeID]
125125
if !ok {
126-
return fmt.Errorf("cound not find child node %s", childNodeID)
126+
return fmt.Errorf("could not find child node %s", childNodeID)
127127
}
128128

129129
parentNode.Deps = append(parentNode.Deps, Dependency{

internal/mocks/uvclient.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ func (m *MockUVClient) ExportSBOM(inputDir string) (*scaplugin.Finding, error) {
1818
return m.ExportSBOMFunc(inputDir)
1919
}
2020
return &scaplugin.Finding{
21-
Sbom: []byte(`{"mock":"sbom"}`),
21+
Sbom: []byte(`{"mock":"sbom"}`),
22+
Metadata: scaplugin.Metadata{
23+
PackageManager: "pip",
24+
Name: "mock-project",
25+
Version: "0.0.0",
26+
},
2227
FileExclusions: []string{},
2328
NormalisedTargetFile: uv.UvLockFileName,
2429
}, nil

internal/uv/uvclient.go

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

53-
if err := validateSBOM(output); err != nil {
53+
metadata, err := validateSBOM(output)
54+
if err != nil {
5455
return nil, err
5556
}
5657

5758
return &scaplugin.Finding{
5859
Sbom: output,
60+
Metadata: *metadata,
5961
FileExclusions: []string{
6062
// TODO(uv): uncomment when we are able to pass these to the CLI correctly. Currently the
6163
// `--exclude` flag does not accept paths, it only accepts file or dir names, which does not
@@ -68,27 +70,34 @@ func (c client) ExportSBOM(inputDir string) (*scaplugin.Finding, error) {
6870
}
6971

7072
// Verifies that the SBOM is valid JSON and has a root component.
71-
func validateSBOM(sbomData []byte) error {
73+
func validateSBOM(sbomData []byte) (*scaplugin.Metadata, error) {
7274
var sbom struct {
7375
Metadata struct {
74-
Component json.RawMessage `json:"component"`
76+
Component *struct {
77+
Name string `json:"name"`
78+
Version string `json:"version"`
79+
} `json:"component"`
7580
} `json:"metadata"`
7681
}
7782

7883
if err := json.Unmarshal(sbomData, &sbom); err != nil {
79-
return ecosystems.NewUnprocessableFileError(
84+
return nil, ecosystems.NewUnprocessableFileError(
8085
fmt.Sprintf("Failed to parse SBOM JSON: %v", err),
8186
snyk_errors.WithCause(err),
8287
)
8388
}
8489

85-
if len(sbom.Metadata.Component) == 0 {
86-
return ecosystems.NewUnprocessableFileError(
90+
if sbom.Metadata.Component == nil {
91+
return nil, ecosystems.NewUnprocessableFileError(
8792
"SBOM missing root component at metadata.component - uv project may be missing a root package",
8893
)
8994
}
9095

91-
return nil
96+
return &scaplugin.Metadata{
97+
PackageManager: "pip",
98+
Name: sbom.Metadata.Component.Name,
99+
Version: sbom.Metadata.Component.Version,
100+
}, nil
92101
}
93102

94103
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
@@ -28,7 +28,8 @@ func TestUVClient_ExportSBOM_Success(t *testing.T) {
2828
"component": {
2929
"type": "library",
3030
"bom-ref": "test-project-1",
31-
"name": "test-project"
31+
"name": "test-project",
32+
"version": "1.2.3"
3233
}
3334
}
3435
}`
@@ -46,7 +47,11 @@ func TestUVClient_ExportSBOM_Success(t *testing.T) {
4647
result, err := client.ExportSBOM("/test/dir")
4748

4849
assert.NoError(t, err)
50+
require.NotNil(t, result)
4951
assert.JSONEq(t, validSBOM, string(result.Sbom))
52+
assert.Equal(t, "pip", result.Metadata.PackageManager)
53+
assert.Equal(t, "test-project", result.Metadata.Name)
54+
assert.Equal(t, "1.2.3", result.Metadata.Version)
5055
}
5156

5257
func TestUVClient_ExportSBOM_Error(t *testing.T) {
@@ -159,39 +164,97 @@ func TestParseAndValidateVersion_UnparseableOutput(t *testing.T) {
159164

160165
func TestValidateSBOM_Success(t *testing.T) {
161166
tests := []struct {
162-
name string
163-
sbom string
167+
name string
168+
sbom string
169+
expectedName string
170+
expectedVersion string
164171
}{
165172
{
166-
name: "valid SBOM with component",
173+
name: "valid SBOM with full component",
167174
sbom: `{
168175
"bomFormat": "CycloneDX",
169176
"specVersion": "1.5",
170177
"metadata": {
171178
"component": {
172179
"type": "library",
173180
"name": "test-project",
181+
"version": "1.0.0",
174182
"bom-ref": "test-project-1"
175183
}
176184
}
177185
}`,
186+
expectedName: "test-project",
187+
expectedVersion: "1.0.0",
178188
},
179189
{
180190
name: "valid SBOM with minimal component",
181191
sbom: `{
182192
"metadata": {
183193
"component": {
184-
"name": "my-project"
194+
"name": "my-project",
195+
"version": "0.1.0"
196+
}
197+
}
198+
}`,
199+
expectedName: "my-project",
200+
expectedVersion: "0.1.0",
201+
},
202+
{
203+
name: "component without version",
204+
sbom: `{
205+
"bomFormat": "CycloneDX",
206+
"specVersion": "1.5",
207+
"metadata": {
208+
"component": {
209+
"name": "project-no-version"
210+
}
211+
}
212+
}`,
213+
expectedName: "project-no-version",
214+
expectedVersion: "",
215+
},
216+
{
217+
name: "component with empty version string",
218+
sbom: `{
219+
"metadata": {
220+
"component": {
221+
"name": "project-empty-version",
222+
"version": ""
223+
}
224+
}
225+
}`,
226+
expectedName: "project-empty-version",
227+
expectedVersion: "",
228+
},
229+
{
230+
name: "component with additional fields",
231+
sbom: `{
232+
"bomFormat": "CycloneDX",
233+
"specVersion": "1.5",
234+
"metadata": {
235+
"component": {
236+
"type": "application",
237+
"name": "complex-project",
238+
"version": "3.0.0",
239+
"bom-ref": "pkg:pypi/[email protected]",
240+
"description": "A complex project",
241+
"licenses": []
185242
}
186243
}
187244
}`,
245+
expectedName: "complex-project",
246+
expectedVersion: "3.0.0",
188247
},
189248
}
190249

191250
for _, tt := range tests {
192251
t.Run(tt.name, func(t *testing.T) {
193-
err := validateSBOM([]byte(tt.sbom))
252+
metadata, err := validateSBOM([]byte(tt.sbom))
194253
assert.NoError(t, err)
254+
require.NotNil(t, metadata)
255+
assert.Equal(t, "pip", metadata.PackageManager)
256+
assert.Equal(t, tt.expectedName, metadata.Name)
257+
assert.Equal(t, tt.expectedVersion, metadata.Version)
195258
})
196259
}
197260
}
@@ -222,7 +285,8 @@ func TestValidateSBOM_MissingComponent(t *testing.T) {
222285

223286
for _, tt := range tests {
224287
t.Run(tt.name, func(t *testing.T) {
225-
err := validateSBOM([]byte(tt.sbom))
288+
metadata, err := validateSBOM([]byte(tt.sbom))
289+
assert.Nil(t, metadata)
226290
require.Error(t, err)
227291
var catalogErr snyk_errors.Error
228292
assert.True(t, errors.As(err, &catalogErr), "error should be a catalog error")
@@ -232,7 +296,8 @@ func TestValidateSBOM_MissingComponent(t *testing.T) {
232296
}
233297

234298
func TestValidateSBOM_InvalidJSON(t *testing.T) {
235-
err := validateSBOM([]byte("invalid json"))
299+
metadata, err := validateSBOM([]byte("invalid json"))
300+
assert.Nil(t, metadata)
236301
require.Error(t, err)
237302
var catalogErr snyk_errors.Error
238303
assert.True(t, errors.As(err, &catalogErr), "error should be a catalog error")

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+
}

0 commit comments

Comments
 (0)