Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions internal/mocks/uvclient.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
package mocks

import "github.com/rs/zerolog"
import (
"github.com/rs/zerolog"

"github.com/snyk/cli-extension-dep-graph/internal/uv"
scaplugin "github.com/snyk/cli-extension-dep-graph/pkg/sca_plugin"
)

// MockUVClient is a mock implementation of UVClient for testing
type MockUVClient struct {
ExportSBOMFunc func(inputDir string) ([]byte, error)
ExportSBOMFunc func(inputDir string) (*scaplugin.Finding, error)
ShouldExportSBOMFunc func(inputDir string, logger *zerolog.Logger) bool
}

func (m *MockUVClient) ExportSBOM(inputDir string) ([]byte, error) {
func (m *MockUVClient) ExportSBOM(inputDir string) (*scaplugin.Finding, error) {
if m.ExportSBOMFunc != nil {
return m.ExportSBOMFunc(inputDir)
}
return []byte(`{"mock":"sbom"}`), nil
return &scaplugin.Finding{
Sbom: []byte(`{"mock":"sbom"}`),
FileExclusions: []string{},
NormalisedTargetFile: uv.UvLockFileName,
}, nil
}

func (m *MockUVClient) ShouldExportSBOM(inputDir string, logger *zerolog.Logger) bool {
Expand All @@ -21,3 +30,5 @@ func (m *MockUVClient) ShouldExportSBOM(inputDir string, logger *zerolog.Logger)
}
return true
}

var _ uv.Client = (*MockUVClient)(nil)
6 changes: 5 additions & 1 deletion internal/uv/uv.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import (
"github.com/rs/zerolog"
)

const UvLockFileName = "uv.lock"
const (
UvLockFileName = "uv.lock"
RequirementsTxtFileName = "requirements.txt"
PyprojectTomlFileName = "pyproject.toml"
)

// This is copied from cli-extension-os-flows. We could export from here via GAF config and re-use if this duplication
// becomes a problem, but this duplication is only temporary.
Expand Down
4 changes: 2 additions & 2 deletions internal/uv/uv_plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ func (p Plugin) BuildFindingsFromDir(inputDir string, _ scaplugin.Options, logge
return []scaplugin.Finding{}, nil
}

sbomOutput, err := p.client.ExportSBOM(inputDir)
finding, err := p.client.ExportSBOM(inputDir)
if err != nil {
return []scaplugin.Finding{}, fmt.Errorf("failed to export SBOM using uv: %w", err)
}
return []scaplugin.Finding{{Sbom: sbomOutput, FilesProcessed: []string{}}}, nil
return []scaplugin.Finding{*finding}, nil
}
17 changes: 14 additions & 3 deletions internal/uv/uvclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import (
"strings"

"github.com/rs/zerolog"
scaplugin "github.com/snyk/cli-extension-dep-graph/pkg/sca_plugin"
)

type Client interface {
ExportSBOM(inputDir string) ([]byte, error)
ExportSBOM(inputDir string) (*scaplugin.Finding, error)
ShouldExportSBOM(inputDir string, logger *zerolog.Logger) bool
}

Expand Down Expand Up @@ -40,7 +41,7 @@ func NewUvClientWithExecutor(uvBinary string, executor cmdExecutor) Client {
}

// exportSBOM exports an SBOM in CycloneDX format using uv.
func (c client) ExportSBOM(inputDir string) ([]byte, error) {
func (c client) ExportSBOM(inputDir string) (*scaplugin.Finding, error) {
output, err := c.executor.Execute(c.uvBinary, inputDir, "export", "--format", "cyclonedx1.5", "--frozen", "--preview")
if err != nil {
return nil, fmt.Errorf("failed to execute uv export: %w", err)
Expand All @@ -50,7 +51,17 @@ func (c client) ExportSBOM(inputDir string) ([]byte, error) {
return nil, err
}

return output, nil
return &scaplugin.Finding{
Sbom: output,
FileExclusions: []string{
// TODO(uv): uncomment when we are able to pass these to the CLI correctly. Currently the
// `--exclude` flag does not accept paths, it only accepts file or dir names, which does not
// work for our use case.
// path.Join(inputDir, uv.RequirementsTxtFileName),
// path.Join(inputDir, uv.PyprojectTomlFileName),
},
NormalisedTargetFile: UvLockFileName,
}, nil
}

// Verifies that the SBOM is valid JSON and has a root component.
Expand Down
2 changes: 1 addition & 1 deletion internal/uv/uvclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func TestUVClient_ExportSBOM_Success(t *testing.T) {
result, err := client.ExportSBOM("/test/dir")

assert.NoError(t, err)
assert.JSONEq(t, validSBOM, string(result))
assert.JSONEq(t, validSBOM, string(result.Sbom))
}

func TestUVClient_ExportSBOM_Error(t *testing.T) {
Expand Down
16 changes: 8 additions & 8 deletions pkg/depgraph/sbom_resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func handleSBOMResolutionDI(
// Convert SBOMs to workflow.Data
workflowData := []workflow.Data{}
for _, f := range findings { // Could be parallelised in future
data, err := sbomToWorkflowData(f.Sbom, snykClient, logger, remoteRepoURL)
data, err := sbomToWorkflowData(f, snykClient, logger, remoteRepoURL)
if err != nil {
return nil, fmt.Errorf("error converting SBOM: %w", err)
}
Expand All @@ -106,7 +106,7 @@ func handleSBOMResolutionDI(
func getExclusionsFromFindings(findings []scaplugin.Finding) []string {
exclusions := []string{}
for _, f := range findings {
exclusions = append(exclusions, f.FilesProcessed...)
exclusions = append(exclusions, f.FileExclusions...)
}
return exclusions
}
Expand Down Expand Up @@ -150,8 +150,8 @@ func executeLegacyWorkflow(
return nil, fmt.Errorf("error handling legacy workflow: %w", err)
}

func sbomToWorkflowData(sbomOutput []byte, snykClient *snykclient.SnykClient, logger *zerolog.Logger, remoteRepoURL string) ([]workflow.Data, error) {
sbomReader := bytes.NewReader(sbomOutput)
func sbomToWorkflowData(finding scaplugin.Finding, snykClient *snykclient.SnykClient, logger *zerolog.Logger, remoteRepoURL string) ([]workflow.Data, error) {
sbomReader := bytes.NewReader(finding.Sbom)

scans, warnings, err := snykClient.SBOMConvert(context.Background(), logger, sbomReader, remoteRepoURL)
if err != nil {
Expand All @@ -160,7 +160,7 @@ func sbomToWorkflowData(sbomOutput []byte, snykClient *snykclient.SnykClient, lo

logger.Printf("Successfully converted SBOM, warning(s): %d\n", len(warnings))

depGraphsData, err := extractDepGraphsFromScans(scans)
depGraphsData, err := extractDepGraphsFromScans(scans, finding.NormalisedTargetFile)
if err != nil {
return nil, fmt.Errorf("failed to extract depgraphs from scan results: %w", err)
}
Expand All @@ -171,7 +171,7 @@ func sbomToWorkflowData(sbomOutput []byte, snykClient *snykclient.SnykClient, lo
return depGraphsData, nil
}

func extractDepGraphsFromScans(scans []*snykclient.ScanResult) ([]workflow.Data, error) {
func extractDepGraphsFromScans(scans []*snykclient.ScanResult, targetFile string) ([]workflow.Data, error) {
var depGraphList []workflow.Data

for _, scan := range scans {
Expand All @@ -189,8 +189,8 @@ func extractDepGraphsFromScans(scans []*snykclient.ScanResult) ([]workflow.Data,
// Create workflow data with the depgraph
data := workflow.NewData(DataTypeID, contentTypeJSON, depGraphBytes)

data.SetMetaData(contentLocationKey, "uv.lock")
data.SetMetaData(MetaKeyNormalisedTargetFile, "uv.lock")
data.SetMetaData(contentLocationKey, targetFile)
data.SetMetaData(MetaKeyNormalisedTargetFile, targetFile)

if scan.Identity.Type != "" {
data.SetMetaData(MetaKeyTargetFileFromPlugin, scan.Identity.Type)
Expand Down
50 changes: 28 additions & 22 deletions pkg/depgraph/sbom_resolution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,10 @@ func Test_callback_SBOMResolution(t *testing.T) {
ctx.config.Set(configuration.API_URL, mockSBOMService.URL)

mockUVClient := &mocks.MockUVClient{
ExportSBOMFunc: func(_ string) ([]byte, error) {
return []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`), nil
ExportSBOMFunc: func(_ string) (*scaplugin.Finding, error) {
return &scaplugin.Finding{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`),
}, nil
},
}

Expand All @@ -159,7 +161,7 @@ func Test_callback_SBOMResolution(t *testing.T) {
resolutionHandler := NewCalledResolutionHandlerFunc(nil, nil)

mockUVClient := &mocks.MockUVClient{
ExportSBOMFunc: func(_ string) ([]byte, error) {
ExportSBOMFunc: func(_ string) (*scaplugin.Finding, error) {
return nil, fmt.Errorf("uv command failed")
},
}
Expand Down Expand Up @@ -187,8 +189,10 @@ func Test_callback_SBOMResolution(t *testing.T) {
ctx.config.Set(configuration.API_URL, mockSBOMService.URL)

mockUVClient := &mocks.MockUVClient{
ExportSBOMFunc: func(_ string) ([]byte, error) {
return []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`), nil
ExportSBOMFunc: func(_ string) (*scaplugin.Finding, error) {
return &scaplugin.Finding{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`),
}, nil
},
}

Expand Down Expand Up @@ -238,8 +242,8 @@ func Test_callback_SBOMResolution(t *testing.T) {
// Create mock plugin that returns two findings
mockPlugin := &mockScaPlugin{
findings: []scaplugin.Finding{
{Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`), FilesProcessed: []string{}},
{Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[{"name":"test"}]}`), FilesProcessed: []string{}},
{Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`), FileExclusions: []string{}},
{Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[{"name":"test"}]}`), FileExclusions: []string{}},
},
}

Expand Down Expand Up @@ -268,8 +272,10 @@ func Test_callback_SBOMResolution(t *testing.T) {
ctx.config.Set(configuration.API_URL, mockSBOMService.URL)

mockUVClient := &mocks.MockUVClient{
ExportSBOMFunc: func(_ string) ([]byte, error) {
return []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`), nil
ExportSBOMFunc: func(_ string) (*scaplugin.Finding, error) {
return &scaplugin.Finding{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`),
}, nil
},
}

Expand All @@ -291,19 +297,19 @@ func Test_callback_SBOMResolution(t *testing.T) {
t.Run("handleSBOMResolution with FlagAllProjects", func(t *testing.T) {
finding1 := scaplugin.Finding{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`),
FilesProcessed: []string{"uv.lock", "pyproject.toml"},
FileExclusions: []string{"uv.lock", "pyproject.toml"},
}
finding2 := scaplugin.Finding{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[{"name":"test"}]}`),
FilesProcessed: []string{"requirements.txt", "setup.py"},
FileExclusions: []string{"requirements.txt", "setup.py"},
}
finding3 := scaplugin.Finding{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[{"name":"someFinding"}]}`),
FilesProcessed: []string{"package.json"},
FileExclusions: []string{"package.json"},
}
finding4 := scaplugin.Finding{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[{"name":"anotherFinding"}]}`),
FilesProcessed: []string{"go.mod"},
FileExclusions: []string{"go.mod"},
}

tc := []struct {
Expand Down Expand Up @@ -514,7 +520,7 @@ func Test_callback_SBOMResolution(t *testing.T) {
findings: []scaplugin.Finding{
{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`),
FilesProcessed: []string{"uv.lock"},
FileExclusions: []string{"uv.lock"},
},
},
}
Expand Down Expand Up @@ -584,7 +590,7 @@ func Test_callback_SBOMResolution(t *testing.T) {
findings: []scaplugin.Finding{
{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}`),
FilesProcessed: []string{"uv.lock"},
FileExclusions: []string{"uv.lock"},
},
},
}
Expand Down Expand Up @@ -626,7 +632,7 @@ func Test_getExclusionsFromFindings(t *testing.T) {
findings: []scaplugin.Finding{
{
Sbom: []byte(`{"bomFormat":"CycloneDX"}`),
FilesProcessed: []string{},
FileExclusions: []string{},
},
},
expected: []string{},
Expand All @@ -636,7 +642,7 @@ func Test_getExclusionsFromFindings(t *testing.T) {
findings: []scaplugin.Finding{
{
Sbom: []byte(`{"bomFormat":"CycloneDX"}`),
FilesProcessed: []string{"file1.py", "file2.py"},
FileExclusions: []string{"file1.py", "file2.py"},
},
},
expected: []string{"file1.py", "file2.py"},
Expand All @@ -646,11 +652,11 @@ func Test_getExclusionsFromFindings(t *testing.T) {
findings: []scaplugin.Finding{
{
Sbom: []byte(`{"bomFormat":"CycloneDX"}`),
FilesProcessed: []string{"file1.py", "file2.py"},
FileExclusions: []string{"file1.py", "file2.py"},
},
{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5"}`),
FilesProcessed: []string{"file3.py", "file4.py", "file5.py"},
FileExclusions: []string{"file3.py", "file4.py", "file5.py"},
},
},
expected: []string{"file1.py", "file2.py", "file3.py", "file4.py", "file5.py"},
Expand All @@ -660,15 +666,15 @@ func Test_getExclusionsFromFindings(t *testing.T) {
findings: []scaplugin.Finding{
{
Sbom: []byte(`{"bomFormat":"CycloneDX"}`),
FilesProcessed: []string{},
FileExclusions: []string{},
},
{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5"}`),
FilesProcessed: []string{"file1.py"},
FileExclusions: []string{"file1.py"},
},
{
Sbom: []byte(`{"bomFormat":"CycloneDX","specVersion":"1.6"}`),
FilesProcessed: []string{"file2.py", "file3.py"},
FileExclusions: []string{"file2.py", "file3.py"},
},
},
expected: []string{"file1.py", "file2.py", "file3.py"},
Expand Down
5 changes: 3 additions & 2 deletions pkg/sca_plugin/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import "github.com/rs/zerolog"
type Options struct{}

type Finding struct {
Sbom Sbom
FilesProcessed []string
Sbom Sbom // The raw SBOM bytes
FileExclusions []string // Paths for files that other plugins should ignore
NormalisedTargetFile string // The target file name without any qualifiers, e.g. `uv.lock` (and not `dir/uv.lock`)
}

type Sbom []byte
Expand Down