From 92617cb4ffc611a910b078896165585980b479fe Mon Sep 17 00:00:00 2001 From: Dennis Tuan Anh Quach <83472928+Dboy0ZDev@users.noreply.github.com> Date: Wed, 20 May 2026 16:43:03 +0200 Subject: [PATCH 01/10] Created Function for OpenVEX ingestion and openvex fetching from github repo --- normalize/sbom_graph.go | 36 +++++++ services/scan_service.go | 104 ++++++++++++++++++ services/scan_service_test.go | 150 ++++++++++++++++++++++++++ services/vex_rule_service.go | 137 ++++++++++++++++++++++-- services/vex_rule_service_test.go | 169 ++++++++++++++++++++++++++++++ 5 files changed, 586 insertions(+), 10 deletions(-) diff --git a/normalize/sbom_graph.go b/normalize/sbom_graph.go index 59288291c..0ac083bbf 100644 --- a/normalize/sbom_graph.go +++ b/normalize/sbom_graph.go @@ -27,6 +27,7 @@ import ( cdx "github.com/CycloneDX/cyclonedx-go" "github.com/google/uuid" + ov "github.com/openvex/go-vex/pkg/vex" "github.com/package-url/packageurl-go" ) @@ -141,6 +142,41 @@ func NewVexReport(report *cdx.BOM, source string) (*VexReport, error) { }, nil } +type VexReportOpenVEX struct { + Report *ov.VEX + Source string +} + +func validateVexReportOpenVEX(report *ov.VEX) error { + if report.ID == "" { + return fmt.Errorf("invalid OpenVEX report: missing id") + } + if report.Context == "" { + return fmt.Errorf("invalid OpenVEX report: missing context") + } + if report.Author == "" { + return fmt.Errorf("invalid OpenVEX report: missing author") + } + if report.Timestamp.IsZero() { + return fmt.Errorf("invalid OpenVEX report: missing timestamp") + } + if report.Version == 0 { + return fmt.Errorf("invalid OpenVEX report: missing version") + } + return nil +} + +func NewVexReportOpenVEX(report *ov.VEX, source string) (*VexReportOpenVEX, error) { + if err := validateVexReportOpenVEX(report); err != nil { + return nil, err + } + + return &VexReportOpenVEX{ + Report: report, + Source: source, + }, nil +} + func edgesToDepMap(edges map[string]map[string]struct{}) map[string][]string { depMap := make(map[string][]string) for parent, children := range edges { diff --git a/services/scan_service.go b/services/scan_service.go index 49482b1be..d6e1346f8 100644 --- a/services/scan_service.go +++ b/services/scan_service.go @@ -22,11 +22,14 @@ import ( "io" "log/slog" "net/http" + "net/url" + "path" "slices" "strings" "time" "github.com/CycloneDX/cyclonedx-go" + "github.com/google/go-github/v62/github" "github.com/google/uuid" "github.com/l3montree-dev/devguard/database/models" databasetypes "github.com/l3montree-dev/devguard/database/types" @@ -38,6 +41,7 @@ import ( "github.com/l3montree-dev/devguard/transformer" "github.com/l3montree-dev/devguard/utils" "github.com/l3montree-dev/devguard/vulndb/scan" + ov "github.com/openvex/go-vex/pkg/vex" "github.com/package-url/packageurl-go" "github.com/pkg/errors" "go.opentelemetry.io/otel/attribute" @@ -63,6 +67,12 @@ type scanService struct { utils.FireAndForgetSynchronizer } +var newGitHubClient = func() *github.Client { + return github.NewClient(nil) +} + +var downloadRawFileFn = DownloadRawFile + var _ shared.ScanService = (*scanService)(nil) func NewScanService( @@ -922,3 +932,97 @@ func (s *scanService) ScanSBOMWithoutSaving(ctx context.Context, bom *cyclonedx. DependencyVulns: vulnDTOs, }, nil } + +func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetUrl string) (vexReports []*normalize.VexReportOpenVEX, err error) { + client := newGitHubClient() + githubDomain := "https://github.com" + if !strings.HasPrefix(targetUrl, githubDomain) { + return nil, fmt.Errorf("invalid github repository url") + } + owner, repo, err := ParseGitHubURL(targetUrl) + if err != nil { + return nil, err + } + + // Determine default branch + repository, _, err := client.Repositories.Get(ctx, owner, repo) + if err != nil { + return nil, err + } + branch := repository.GetDefaultBranch() + if branch == "" { + branch = "main" + } + + tree, _, err := client.Git.GetTree( + ctx, + owner, + repo, + branch, + true, // recursive + ) + if err != nil { + + return nil, err + } + for _, entry := range tree.Entries { + if entry.GetType() != "blob" { + continue + } + filePath := entry.GetPath() + filename := strings.ToLower(path.Base(filePath)) + if !strings.HasSuffix(filename, ".json") { + continue + } + + content, err := downloadRawFileFn( + owner, + repo, + branch, + filePath, + ) + if err != nil { + slog.Info("download of openVEX failed", "err", err) + continue + } + var openVEX ov.VEX + err = json.Unmarshal(content, &openVEX) + if err != nil { + slog.Info("could not unmarshal openVEX failed", "err", err) + continue + } + + vexReports = append(vexReports, &normalize.VexReportOpenVEX{ + Report: &openVEX, + Source: targetUrl, + }) + } + return vexReports, nil +} + +func ParseGitHubURL(rawURL string) (owner string, repo string, err error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", "", err + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + return parts[0], parts[1], nil +} + +func DownloadRawFile(owner, repo, branch, filePath string) ([]byte, error) { + + rawURL := fmt.Sprintf( + "https://raw.githubusercontent.com/%s/%s/%s/%s", + owner, + repo, + branch, + filePath, + ) + resp, err := http.Get(rawURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) + +} diff --git a/services/scan_service_test.go b/services/scan_service_test.go index a1b56e90b..a459ebb08 100644 --- a/services/scan_service_test.go +++ b/services/scan_service_test.go @@ -16,10 +16,14 @@ package services import ( "context" + "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" + "time" + "github.com/google/go-github/v62/github" "github.com/google/uuid" "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/dtos" @@ -290,3 +294,149 @@ func TestFetchSbomsFromUpstream_PassesURLNotRef(t *testing.T) { assert.Equal(t, sbomURL, invalidURLs[0].URL) }) } + +func TestFetchOpenVexFromGitHub(t *testing.T) { + originalNewGitHubClient := newGitHubClient + originalDownloadRawFileFn := downloadRawFileFn + t.Cleanup(func() { + newGitHubClient = originalNewGitHubClient + downloadRawFileFn = originalDownloadRawFileFn + }) + + t.Run("should fetch openvex reports from json files in the repository", func(t *testing.T) { + mockGitHub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/openvex-repo": + _, _ = w.Write([]byte(`{"default_branch":"main"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/openvex-repo/git/trees/main": + if got := r.URL.Query().Get("recursive"); got != "1" { + t.Fatalf("expected recursive=1, got %q", got) + } + _, _ = w.Write([]byte(`{"tree":[{"path":"reports/openvex.json","type":"blob"},{"path":"README.md","type":"blob"}]}`)) + default: + t.Fatalf("unexpected github api request: %s %s", r.Method, r.URL.String()) + } + })) + defer mockGitHub.Close() + + newGitHubClient = func() *github.Client { + client := github.NewClient(mockGitHub.Client()) + baseURL, err := url.Parse(mockGitHub.URL + "/") + if err != nil { + t.Fatalf("failed to parse mock github url: %v", err) + } + client.BaseURL = baseURL + client.UploadURL = baseURL + return client + } + + calls := 0 + downloadRawFileFn = func(owner, repo, branch, filePath string) ([]byte, error) { + calls++ + assert.Equal(t, "octo-org", owner) + assert.Equal(t, "openvex-repo", repo) + assert.Equal(t, "main", branch) + assert.Equal(t, "reports/openvex.json", filePath) + + ts := time.Date(2026, time.May, 20, 12, 0, 0, 0, time.UTC) + payload := map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "openvex-1", + "author": "test-author", + "timestamp": ts, + "version": 1, + "statements": []any{}, + } + return json.Marshal(payload) + } + + service := &scanService{} + reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://github.com/octo-org/openvex-repo") + assert.NoError(t, err) + assert.Len(t, reports, 1) + assert.Equal(t, "https://github.com/octo-org/openvex-repo", reports[0].Source) + assert.Equal(t, "openvex-1", reports[0].Report.ID) + assert.Equal(t, "test-author", reports[0].Report.Author) + assert.Equal(t, 1, reports[0].Report.Version) + assert.Equal(t, 1, calls) + }) + + t.Run("should fetch multiple openvex reports from multiple json files", func(t *testing.T) { + mockGitHub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/multi-vex-repo": + _, _ = w.Write([]byte(`{"default_branch":"develop"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/multi-vex-repo/git/trees/develop": + if got := r.URL.Query().Get("recursive"); got != "1" { + t.Fatalf("expected recursive=1, got %q", got) + } + _, _ = w.Write([]byte(`{"tree":[{"path":"vex/vex1.json","type":"blob"},{"path":"vex/vex2.json","type":"blob"},{"path":"README.md","type":"blob"}]}`)) + default: + t.Fatalf("unexpected github api request: %s %s", r.Method, r.URL.String()) + } + })) + defer mockGitHub.Close() + + newGitHubClient = func() *github.Client { + client := github.NewClient(mockGitHub.Client()) + baseURL, err := url.Parse(mockGitHub.URL + "/") + if err != nil { + t.Fatalf("failed to parse mock github url: %v", err) + } + client.BaseURL = baseURL + client.UploadURL = baseURL + return client + } + + calls := 0 + downloadRawFileFn = func(owner, repo, branch, filePath string) ([]byte, error) { + calls++ + assert.Equal(t, "octo-org", owner) + assert.Equal(t, "multi-vex-repo", repo) + assert.Equal(t, "develop", branch) + + ts := time.Date(2026, time.May, 20, 12, 0, 0, 0, time.UTC) + var id, author string + + if filePath == "vex/vex1.json" { + id = "openvex-first" + author = "author-one" + } else if filePath == "vex/vex2.json" { + id = "openvex-second" + author = "author-two" + } else { + t.Fatalf("unexpected file path: %s", filePath) + } + + payload := map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": id, + "author": author, + "timestamp": ts, + "version": 1, + "statements": []any{}, + } + return json.Marshal(payload) + } + + service := &scanService{} + reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://github.com/octo-org/multi-vex-repo") + assert.NoError(t, err) + assert.Len(t, reports, 2) + assert.Equal(t, "https://github.com/octo-org/multi-vex-repo", reports[0].Source) + assert.Equal(t, "https://github.com/octo-org/multi-vex-repo", reports[1].Source) + assert.Equal(t, "openvex-first", reports[0].Report.ID) + assert.Equal(t, "openvex-second", reports[1].Report.ID) + assert.Equal(t, "author-one", reports[0].Report.Author) + assert.Equal(t, "author-two", reports[1].Report.Author) + assert.Equal(t, 2, calls) + }) + + t.Run("should reject non github urls", func(t *testing.T) { + service := &scanService{} + reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://example.com/repo") + assert.Error(t, err) + assert.Nil(t, reports) + assert.Contains(t, err.Error(), "invalid github repository url") + }) +} diff --git a/services/vex_rule_service.go b/services/vex_rule_service.go index 1b0ec64e1..49e30d44b 100644 --- a/services/vex_rule_service.go +++ b/services/vex_rule_service.go @@ -24,6 +24,7 @@ import ( "strings" cdx "github.com/CycloneDX/cyclonedx-go" + ov "github.com/openvex/go-vex/pkg/vex" "github.com/package-url/packageurl-go" "github.com/google/uuid" @@ -492,18 +493,14 @@ func (s *VEXRuleService) parseVEXRulesInBOM(ctx context.Context, assetID uuid.UU var pattern dtos.PathPattern - if componentPurl.String() != "" { - componentPurlStr, err := normalize.PURLToString(componentPurl) - if err != nil { - slog.Info("failed to unescape component purl for path pattern, continuing anyway", "purl", componentPurl.String(), "error", err) - componentPurlStr = componentPurl.String() - } - pattern = dtos.PathPattern{componentPurlStr, dtos.PathPatternWildcard, purlString} - } else { - // If no metadata component PURL, use the affected package directly - pattern = dtos.PathPattern{purlString} + componentPurlStr, err := normalize.PURLToString(componentPurl) + if err != nil { + slog.Info("failed to unescape component purl for path pattern, continuing anyway", "purl", componentPurl.String(), "error", err) + componentPurlStr = componentPurl.String() } + pattern = dtos.PathPattern{componentPurlStr, dtos.PathPatternWildcard, purlString} + rule := models.VEXRule{ AssetID: assetID, AssetVersionName: assetVersionName, @@ -521,6 +518,126 @@ func (s *VEXRuleService) parseVEXRulesInBOM(ctx context.Context, assetID uuid.UU return rules, nil } +func (s *VEXRuleService) parseVEXRulesFromOpenVEXReport(ctx context.Context, assetID uuid.UUID, assetVersionName string, report *normalize.VexReportOpenVEX) ([]models.VEXRule, error) { + vex := report.Report + + if vex.Statements == nil { + return nil, fmt.Errorf("no statements inside OpenVex Report") + } + + rules := make([]models.VEXRule, 0, len(*&vex.Statements)) + for _, statement := range vex.Statements { + if statement.ID == "" { + slog.Info("statement does not contain ID, skipping component for VEX rule creation", "openVEXReport", vex.ID) + continue + } + cveID := string(statement.Vulnerability.Name) + if cveID == "" { + slog.Info("statment does not contain vulnerability name or identifier, skipping component for VEX rule creation", "statement", statement.ID) + continue + } + if statement.Products == nil { + slog.Info("no products inside of statement, skipping component for VEX rule creation", "statement", statement.ID, "cveID", cveID) + continue + } + + for _, product := range statement.Products { + var err error + var componentPurl packageurl.PackageURL + if product.Identifiers != nil && product.Identifiers[ov.PURL] != "" { + fmt.Println(product.Identifiers[ov.PURL]) + componentPurl, err = packageurl.FromString(product.Identifiers[ov.PURL]) + } else if product.ID != "" { + componentPurl, err = packageurl.FromString(product.ID) + } else { + slog.Info("product identifier is not present, skipping VEX rule creation for this vuln", "statement", statement.ID, "cveID", cveID) + continue + } + + if err != nil { + slog.Info("failed to parse product identifier therefore no identifier present, skipping VEX rule creation for this vuln", "statement", statement.ID, "cveID", cveID) + continue + } + + justification := statement.ImpactStatement + mechanicalJustification := dtos.MechanicalJustificationType(statement.Justification) + + eventType, err := mapOpenVEXToEventType(&statement) + if err != nil { + slog.Info("unable to map OpenVEX Statement to event type, skipping VEX rule creation for this vuln", "cveID", cveID, "error", err) + continue + } + + componentPurlStr, err := normalize.PURLToString(componentPurl) + if err != nil { + slog.Info("failed to unescape component purl for path pattern, continuing anyway", "purl", componentPurl.String(), "error", err) + componentPurlStr = componentPurl.String() + } + + if product.Subcomponents != nil { + for _, subcomponent := range product.Subcomponents { + var subcomponentPurl packageurl.PackageURL + subcomponentPurl, err = packageurl.FromString(subcomponent.ID) + if err != nil { + slog.Info("failed to parse product subcomponent identifier therefore no identifier present, skipping VEX rule creation for this vuln", "statement", statement.ID, "cveID", cveID, "product", componentPurlStr) + continue + } + subcomponentPurlStr, err := normalize.PURLToString(subcomponentPurl) + if err != nil { + subcomponentPurlStr = subcomponentPurl.String() + slog.Info("failed to unescape subcomponent purl for path pattern, continuing anyway", "purl", subcomponentPurlStr, "error", err) + } + pattern := dtos.PathPattern{componentPurlStr, dtos.PathPatternWildcard, subcomponentPurlStr} + + rule := models.VEXRule{ + AssetID: assetID, + AssetVersionName: assetVersionName, + CVEID: cveID, + VexSource: report.Source, + Justification: justification, + EventType: eventType, + PathPattern: pattern, + MechanicalJustification: mechanicalJustification, + CreatedByID: "system", + } + rule.SetPathPattern(rule.PathPattern) + rules = append(rules, rule) + } + } else { + pattern := dtos.PathPattern{componentPurlStr} + rule := models.VEXRule{ + AssetID: assetID, + AssetVersionName: assetVersionName, + CVEID: cveID, + VexSource: report.Source, + Justification: justification, + MechanicalJustification: mechanicalJustification, + EventType: eventType, + PathPattern: pattern, + CreatedByID: "system", + } + rule.SetPathPattern(rule.PathPattern) + rules = append(rules, rule) + } + } + } + return rules, nil +} + +func mapOpenVEXToEventType(s *ov.Statement) (dtos.VulnEventType, error) { + if s == nil { + return "", fmt.Errorf("statement is nil") + } + switch s.Status { + case ov.StatusNotAffected: + return dtos.EventTypeFalsePositive, nil + case ov.StatusAffected: + return dtos.EventTypeAccepted, nil + default: + return "", fmt.Errorf("unknown vulnerability analysis state: %s", s.Status) + } +} + // SyncVEXRulesFromSource syncs VEX rules from a specific source. // It fetches existing rules for the given asset and vexSource, compares them with // the new rules, adds new ones and removes ones that no longer exist. diff --git a/services/vex_rule_service_test.go b/services/vex_rule_service_test.go index 30983be36..855834545 100644 --- a/services/vex_rule_service_test.go +++ b/services/vex_rule_service_test.go @@ -3,6 +3,7 @@ package services import ( "context" "testing" + "time" cdx "github.com/CycloneDX/cyclonedx-go" "github.com/google/uuid" @@ -10,6 +11,7 @@ import ( "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/mocks" "github.com/l3montree-dev/devguard/normalize" + ov "github.com/openvex/go-vex/pkg/vex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -845,6 +847,173 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { vexRuleRepo.AssertExpectations(t) } +func TestParseVEXRulesFromOpenVEXReport(t *testing.T) { + assetID := uuid.New() + service := NewVEXRuleService(nil, nil, nil) + + testCases := []struct { + name string + product ov.Product + wantPathPattern []string + }{ + { + name: "falls back to product id when identifiers are nil", + product: ov.Product{ + Component: ov.Component{ + ID: "pkg:npm/@myorg/myapp@1.0.0", + }, + }, + wantPathPattern: []string{"pkg:npm/@myorg/myapp@1.0.0"}, + }, + { + name: "uses purl identifier when present", + product: ov.Product{ + Component: ov.Component{ + ID: "pkg:npm/ignored@0.0.0", + Identifiers: map[ov.IdentifierType]string{ + ov.PURL: "pkg:npm/@myorg/myapp@1.0.0", + }, + }, + }, + wantPathPattern: []string{"pkg:npm/@myorg/myapp@1.0.0"}, + }, + } + ts := time.Now().UTC() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + report := &normalize.VexReportOpenVEX{ + Source: "test-source", + Report: &ov.VEX{ + Metadata: ov.Metadata{ + ID: "openvex-report-1", + Context: "https://openvex.dev/ns/v0.2.0", + Author: "test-author", + Version: 1, + Timestamp: &ts, + }, + Statements: []ov.Statement{ + { + ID: "stmt-1", + Vulnerability: ov.Vulnerability{ + Name: "CVE-2024-1234", + }, + Status: ov.StatusNotAffected, + ImpactStatement: "not affected", + Justification: "component_not_present", + Products: []ov.Product{tc.product}, + }, + }, + }, + } + + rules, err := service.parseVEXRulesFromOpenVEXReport(context.Background(), assetID, "v1.0", report) + assert.NoError(t, err) + assert.Len(t, rules, 1) + + rule := rules[0] + assert.Equal(t, assetID, rule.AssetID) + assert.Equal(t, "v1.0", rule.AssetVersionName) + assert.Equal(t, "CVE-2024-1234", rule.CVEID) + assert.Equal(t, dtos.EventTypeFalsePositive, rule.EventType) + assert.Equal(t, tc.wantPathPattern, []string(rule.PathPattern)) + assert.Equal(t, "not affected", rule.Justification) + assert.Equal(t, dtos.MechanicalJustificationType("component_not_present"), rule.MechanicalJustification) + }) + } +} + +// TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements verifies +// parsing a normal OpenVEX report with multiple statements produces one +// VEX rule per statement. +func TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements(t *testing.T) { + assetID := uuid.New() + service := NewVEXRuleService(nil, nil, nil) + + ts := time.Now().UTC() + report := &normalize.VexReportOpenVEX{ + Source: "test-source", + Report: &ov.VEX{ + Metadata: ov.Metadata{ + ID: "openvex-report-2", + Context: "https://openvex.dev/ns/v0.2.0", + Author: "test-author", + Version: 1, + Timestamp: &ts, + }, + Statements: []ov.Statement{ + { + ID: "stmt-1", + Vulnerability: ov.Vulnerability{ + Name: "CVE-2024-1111", + }, + Status: ov.StatusNotAffected, + Justification: "component_not_present", + Products: []ov.Product{ + { + Component: ov.Component{ + ID: "pkg:golang/app@1.0", + Identifiers: map[ov.IdentifierType]string{ + ov.PURL: "pkg:golang/app@1.0", + }, + }, + }, + }, + }, + { + ID: "stmt-2", + Vulnerability: ov.Vulnerability{ + Name: "CVE-2024-2222", + }, + Status: ov.StatusNotAffected, + Justification: "component_not_present", + Products: []ov.Product{ + { + Component: ov.Component{ + ID: "pkg:golang/lib@2.0", + Identifiers: map[ov.IdentifierType]string{}, + }, + Subcomponents: []ov.Subcomponent{ + { + Component: ov.Component{ + ID: "pkg:golang/lib/sub@2.0", + }, + }, + }, + }, + { + Component: ov.Component{ + ID: "pkg:golang/app@1.0", + Identifiers: map[ov.IdentifierType]string{ + ov.PURL: "pkg:golang/app@1.0", + }, + }, + }, + }, + }, + }, + }, + } + + rules, err := service.parseVEXRulesFromOpenVEXReport(context.Background(), assetID, "v1.0", report) + assert.NoError(t, err) + + expected := []struct { + cve string + path []string + }{ + {cve: "CVE-2024-1111", path: []string{"pkg:golang/app@1.0"}}, + {cve: "CVE-2024-2222", path: []string{"pkg:golang/lib@2.0", dtos.PathPatternWildcard, "pkg:golang/lib/sub@2.0"}}, + {cve: "CVE-2024-2222", path: []string{"pkg:golang/app@1.0"}}, + } + + assert.Len(t, rules, len(expected), "number of generated rules should match expected") + + // We check by order, results and expected results have to line up for this test + for i, exp := range expected { + assert.Equal(t, exp.path, []string(rules[i].PathPattern), "path pattern for %s", exp.cve) + } +} + // TestMatchRulesToVulns_ComponentPurlWithAtSign verifies that rules with properly // unescaped component PURLs (containing @) correctly match vulnerabilities. func TestMatchRulesToVulns_ComponentPurlWithAtSign(t *testing.T) { From 0a7b9f77ed0674806b86bb9c80fb76f09682d70e Mon Sep 17 00:00:00 2001 From: Dennis Tuan Anh Quach <83472928+Dboy0ZDev@users.noreply.github.com> Date: Wed, 20 May 2026 17:02:34 +0200 Subject: [PATCH 02/10] Fixed linting errors --- services/scan_service_test.go | 8 ++++---- services/vex_rule_service.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/services/scan_service_test.go b/services/scan_service_test.go index a459ebb08..e91c29253 100644 --- a/services/scan_service_test.go +++ b/services/scan_service_test.go @@ -397,14 +397,14 @@ func TestFetchOpenVexFromGitHub(t *testing.T) { ts := time.Date(2026, time.May, 20, 12, 0, 0, 0, time.UTC) var id, author string - - if filePath == "vex/vex1.json" { + switch { + case filePath == "vex/vex1.json": id = "openvex-first" author = "author-one" - } else if filePath == "vex/vex2.json" { + case filePath == "vex/vex2.json": id = "openvex-second" author = "author-two" - } else { + default: t.Fatalf("unexpected file path: %s", filePath) } diff --git a/services/vex_rule_service.go b/services/vex_rule_service.go index 49e30d44b..ce5b549f2 100644 --- a/services/vex_rule_service.go +++ b/services/vex_rule_service.go @@ -525,7 +525,7 @@ func (s *VEXRuleService) parseVEXRulesFromOpenVEXReport(ctx context.Context, ass return nil, fmt.Errorf("no statements inside OpenVex Report") } - rules := make([]models.VEXRule, 0, len(*&vex.Statements)) + rules := make([]models.VEXRule, 0, len(vex.Statements)) for _, statement := range vex.Statements { if statement.ID == "" { slog.Info("statement does not contain ID, skipping component for VEX rule creation", "openVEXReport", vex.ID) From 6af4f99f36366d1f08167361445dd8a30db2608a Mon Sep 17 00:00:00 2001 From: Dennis Tuan Anh Quach <83472928+Dboy0ZDev@users.noreply.github.com> Date: Wed, 20 May 2026 17:10:36 +0200 Subject: [PATCH 03/10] Fixed linting issues --- services/scan_service.go | 8 ++++---- services/scan_service_test.go | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/services/scan_service.go b/services/scan_service.go index d6e1346f8..fe2645f4d 100644 --- a/services/scan_service.go +++ b/services/scan_service.go @@ -933,13 +933,13 @@ func (s *scanService) ScanSBOMWithoutSaving(ctx context.Context, bom *cyclonedx. }, nil } -func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetUrl string) (vexReports []*normalize.VexReportOpenVEX, err error) { +func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetURL string) (vexReports []*normalize.VexReportOpenVEX, err error) { client := newGitHubClient() githubDomain := "https://github.com" - if !strings.HasPrefix(targetUrl, githubDomain) { + if !strings.HasPrefix(targetURL, githubDomain) { return nil, fmt.Errorf("invalid github repository url") } - owner, repo, err := ParseGitHubURL(targetUrl) + owner, repo, err := ParseGitHubURL(targetURL) if err != nil { return nil, err } @@ -994,7 +994,7 @@ func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetUrl stri vexReports = append(vexReports, &normalize.VexReportOpenVEX{ Report: &openVEX, - Source: targetUrl, + Source: targetURL, }) } return vexReports, nil diff --git a/services/scan_service_test.go b/services/scan_service_test.go index e91c29253..9eec4d54e 100644 --- a/services/scan_service_test.go +++ b/services/scan_service_test.go @@ -397,11 +397,11 @@ func TestFetchOpenVexFromGitHub(t *testing.T) { ts := time.Date(2026, time.May, 20, 12, 0, 0, 0, time.UTC) var id, author string - switch { - case filePath == "vex/vex1.json": + switch filePath { + case "vex/vex1.json": id = "openvex-first" author = "author-one" - case filePath == "vex/vex2.json": + case "vex/vex2.json": id = "openvex-second" author = "author-two" default: From e25787cda7c6ddad509b4bd3cbdee95ba1e5a074 Mon Sep 17 00:00:00 2001 From: Dennis Tuan Anh Quach <83472928+Dboy0ZDev@users.noreply.github.com> Date: Thu, 21 May 2026 12:08:50 +0200 Subject: [PATCH 04/10] Added removal of debug prints, subcomponent checks, eventType accepted handling, time nil pointer check, http request improvements and url checks --- normalize/sbom_graph.go | 2 +- services/scan_service.go | 46 ++++++++++++++++++++++++------- services/scan_service_test.go | 4 +-- services/vex_rule_service.go | 19 +++++++++---- services/vex_rule_service_test.go | 39 ++++++++++++++++++++------ 5 files changed, 84 insertions(+), 26 deletions(-) diff --git a/normalize/sbom_graph.go b/normalize/sbom_graph.go index 0ac083bbf..f1adf9c4d 100644 --- a/normalize/sbom_graph.go +++ b/normalize/sbom_graph.go @@ -157,7 +157,7 @@ func validateVexReportOpenVEX(report *ov.VEX) error { if report.Author == "" { return fmt.Errorf("invalid OpenVEX report: missing author") } - if report.Timestamp.IsZero() { + if report.Timestamp != nil && report.Timestamp.IsZero() { return fmt.Errorf("invalid OpenVEX report: missing timestamp") } if report.Version == 0 { diff --git a/services/scan_service.go b/services/scan_service.go index fe2645f4d..69ae3c272 100644 --- a/services/scan_service.go +++ b/services/scan_service.go @@ -68,7 +68,10 @@ type scanService struct { } var newGitHubClient = func() *github.Client { - return github.NewClient(nil) + return github.NewClient(&http.Client{ + Transport: utils.EgressTransport, + Timeout: 10 * time.Minute, + }) } var downloadRawFileFn = DownloadRawFile @@ -935,10 +938,6 @@ func (s *scanService) ScanSBOMWithoutSaving(ctx context.Context, bom *cyclonedx. func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetURL string) (vexReports []*normalize.VexReportOpenVEX, err error) { client := newGitHubClient() - githubDomain := "https://github.com" - if !strings.HasPrefix(targetURL, githubDomain) { - return nil, fmt.Errorf("invalid github repository url") - } owner, repo, err := ParseGitHubURL(targetURL) if err != nil { return nil, err @@ -976,6 +975,7 @@ func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetURL stri } content, err := downloadRawFileFn( + ctx, owner, repo, branch, @@ -1005,11 +1005,23 @@ func ParseGitHubURL(rawURL string) (owner string, repo string, err error) { if err != nil { return "", "", err } + const githubDomain = "github.com" + if u.Host != githubDomain { + return "", "", fmt.Errorf("invalid github repository url") + } parts := strings.Split(strings.Trim(u.Path, "/"), "/") - return parts[0], parts[1], nil + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid github repository url path: expected /{owner}/{repo}, got %q", u.Path) + } + owner = parts[0] + repo = strings.TrimSuffix(parts[1], ".git") + if owner == "" || repo == "" { + return "", "", fmt.Errorf("invalid github repository url path: expected non-empty owner and repo, got %q", u.Path) + } + return owner, repo, nil } -func DownloadRawFile(owner, repo, branch, filePath string) ([]byte, error) { +func DownloadRawFile(ctx context.Context, owner, repo, branch, filePath string) ([]byte, error) { rawURL := fmt.Sprintf( "https://raw.githubusercontent.com/%s/%s/%s/%s", @@ -1018,11 +1030,25 @@ func DownloadRawFile(owner, repo, branch, filePath string) ([]byte, error) { branch, filePath, ) - resp, err := http.Get(rawURL) + resp, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } defer resp.Body.Close() - return io.ReadAll(resp.Body) - + switch resp.Response.StatusCode { + case http.StatusOK: + file, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("401 Unauthorized") + } + return file, nil + case http.StatusNotFound: + return nil, fmt.Errorf("404 Source not found") + case http.StatusUnauthorized: + return nil, fmt.Errorf("401 Unauthorized") + case http.StatusInternalServerError: + return nil, fmt.Errorf("500 Internal Server error") + default: + return nil, fmt.Errorf("Unexpected status: %d\n", resp.Response.StatusCode) + } } diff --git a/services/scan_service_test.go b/services/scan_service_test.go index 9eec4d54e..518f4c76f 100644 --- a/services/scan_service_test.go +++ b/services/scan_service_test.go @@ -331,7 +331,7 @@ func TestFetchOpenVexFromGitHub(t *testing.T) { } calls := 0 - downloadRawFileFn = func(owner, repo, branch, filePath string) ([]byte, error) { + downloadRawFileFn = func(ctx context.Context, owner, repo, branch, filePath string) ([]byte, error) { calls++ assert.Equal(t, "octo-org", owner) assert.Equal(t, "openvex-repo", repo) @@ -389,7 +389,7 @@ func TestFetchOpenVexFromGitHub(t *testing.T) { } calls := 0 - downloadRawFileFn = func(owner, repo, branch, filePath string) ([]byte, error) { + downloadRawFileFn = func(ctx context.Context, owner, repo, branch, filePath string) ([]byte, error) { calls++ assert.Equal(t, "octo-org", owner) assert.Equal(t, "multi-vex-repo", repo) diff --git a/services/vex_rule_service.go b/services/vex_rule_service.go index ce5b549f2..a8f959c23 100644 --- a/services/vex_rule_service.go +++ b/services/vex_rule_service.go @@ -545,7 +545,6 @@ func (s *VEXRuleService) parseVEXRulesFromOpenVEXReport(ctx context.Context, ass var err error var componentPurl packageurl.PackageURL if product.Identifiers != nil && product.Identifiers[ov.PURL] != "" { - fmt.Println(product.Identifiers[ov.PURL]) componentPurl, err = packageurl.FromString(product.Identifiers[ov.PURL]) } else if product.ID != "" { componentPurl, err = packageurl.FromString(product.ID) @@ -559,7 +558,14 @@ func (s *VEXRuleService) parseVEXRulesFromOpenVEXReport(ctx context.Context, ass continue } - justification := statement.ImpactStatement + var justification string + switch statement.Status { + case ov.StatusAffected: + justification = statement.ActionStatement + case ov.StatusNotAffected: + justification = statement.ImpactStatement + } + mechanicalJustification := dtos.MechanicalJustificationType(statement.Justification) eventType, err := mapOpenVEXToEventType(&statement) @@ -574,7 +580,7 @@ func (s *VEXRuleService) parseVEXRulesFromOpenVEXReport(ctx context.Context, ass componentPurlStr = componentPurl.String() } - if product.Subcomponents != nil { + if len(product.Subcomponents) > 0 { for _, subcomponent := range product.Subcomponents { var subcomponentPurl packageurl.PackageURL subcomponentPurl, err = packageurl.FromString(subcomponent.ID) @@ -632,9 +638,12 @@ func mapOpenVEXToEventType(s *ov.Statement) (dtos.VulnEventType, error) { case ov.StatusNotAffected: return dtos.EventTypeFalsePositive, nil case ov.StatusAffected: - return dtos.EventTypeAccepted, nil + if s.ActionStatement != "" { + return dtos.EventTypeComment, nil + } + return "", fmt.Errorf("vulnerability analysis state is exploitable, no event type mapping") default: - return "", fmt.Errorf("unknown vulnerability analysis state: %s", s.Status) + return "", fmt.Errorf("unsupported OpenVEX vulnerability analysis state: %s", s.Status) } } diff --git a/services/vex_rule_service_test.go b/services/vex_rule_service_test.go index 855834545..1764c59e8 100644 --- a/services/vex_rule_service_test.go +++ b/services/vex_rule_service_test.go @@ -847,7 +847,7 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { vexRuleRepo.AssertExpectations(t) } -func TestParseVEXRulesFromOpenVEXReport(t *testing.T) { +func TestParseVEXRulesFromOpenVEXReport_SelectValidProductID(t *testing.T) { assetID := uuid.New() service := NewVEXRuleService(nil, nil, nil) @@ -947,7 +947,7 @@ func TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements(t *testing.T Name: "CVE-2024-1111", }, Status: ov.StatusNotAffected, - Justification: "component_not_present", + Justification: ov.ComponentNotPresent, Products: []ov.Product{ { Component: ov.Component{ @@ -965,7 +965,7 @@ func TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements(t *testing.T Name: "CVE-2024-2222", }, Status: ov.StatusNotAffected, - Justification: "component_not_present", + Justification: ov.ComponentNotPresent, Products: []ov.Product{ { Component: ov.Component{ @@ -990,6 +990,24 @@ func TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements(t *testing.T }, }, }, + { + ID: "stmt-3", + Vulnerability: ov.Vulnerability{ + Name: "CVE-2024-3333", + }, + Status: ov.StatusAffected, + ActionStatement: "Update", + Products: []ov.Product{ + { + Component: ov.Component{ + ID: "pkg:golang/app@1.0", + Identifiers: map[ov.IdentifierType]string{ + ov.PURL: "pkg:golang/app@1.0", + }, + }, + }, + }, + }, }, }, } @@ -998,12 +1016,15 @@ func TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements(t *testing.T assert.NoError(t, err) expected := []struct { - cve string - path []string + cve string + path []string + mechanicalJustification string + eventType dtos.VulnEventType }{ - {cve: "CVE-2024-1111", path: []string{"pkg:golang/app@1.0"}}, - {cve: "CVE-2024-2222", path: []string{"pkg:golang/lib@2.0", dtos.PathPatternWildcard, "pkg:golang/lib/sub@2.0"}}, - {cve: "CVE-2024-2222", path: []string{"pkg:golang/app@1.0"}}, + {cve: "CVE-2024-1111", path: []string{"pkg:golang/app@1.0"}, mechanicalJustification: string(ov.ComponentNotPresent), eventType: dtos.EventTypeFalsePositive}, + {cve: "CVE-2024-2222", path: []string{"pkg:golang/lib@2.0", dtos.PathPatternWildcard, "pkg:golang/lib/sub@2.0"}, mechanicalJustification: string(ov.ComponentNotPresent), eventType: dtos.EventTypeFalsePositive}, + {cve: "CVE-2024-2222", path: []string{"pkg:golang/app@1.0"}, mechanicalJustification: string(ov.ComponentNotPresent), eventType: dtos.EventTypeFalsePositive}, + {cve: "CVE-2024-3333", path: []string{"pkg:golang/app@1.0"}, mechanicalJustification: "", eventType: dtos.EventTypeComment}, } assert.Len(t, rules, len(expected), "number of generated rules should match expected") @@ -1011,6 +1032,8 @@ func TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements(t *testing.T // We check by order, results and expected results have to line up for this test for i, exp := range expected { assert.Equal(t, exp.path, []string(rules[i].PathPattern), "path pattern for %s", exp.cve) + assert.Equal(t, exp.mechanicalJustification, string(rules[i].MechanicalJustification), "justification for %s", exp.cve) + assert.Equal(t, exp.eventType, rules[i].EventType, "eventType for %s", exp.cve) } } From b996180913d01dace25fd598f271715774847f68 Mon Sep 17 00:00:00 2001 From: Dennis Tuan Anh Quach <83472928+Dboy0ZDev@users.noreply.github.com> Date: Thu, 21 May 2026 12:13:27 +0200 Subject: [PATCH 05/10] Changed timestamp nil check --- normalize/sbom_graph.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/normalize/sbom_graph.go b/normalize/sbom_graph.go index f1adf9c4d..1a7a0e398 100644 --- a/normalize/sbom_graph.go +++ b/normalize/sbom_graph.go @@ -157,7 +157,7 @@ func validateVexReportOpenVEX(report *ov.VEX) error { if report.Author == "" { return fmt.Errorf("invalid OpenVEX report: missing author") } - if report.Timestamp != nil && report.Timestamp.IsZero() { + if report.Timestamp == nil || report.Timestamp.IsZero() { return fmt.Errorf("invalid OpenVEX report: missing timestamp") } if report.Version == 0 { From 7d9e6975cd42c76165c5b59467b8ee254438f81a Mon Sep 17 00:00:00 2001 From: Dennis Tuan Anh Quach <83472928+Dboy0ZDev@users.noreply.github.com> Date: Fri, 22 May 2026 11:48:24 +0200 Subject: [PATCH 06/10] Implemented download and repo traversal based on single zip download --- services/scan_service.go | 82 +++++++---------- services/scan_service_test.go | 166 ++++++++++++++++------------------ 2 files changed, 109 insertions(+), 139 deletions(-) diff --git a/services/scan_service.go b/services/scan_service.go index 69ae3c272..a31c42fc8 100644 --- a/services/scan_service.go +++ b/services/scan_service.go @@ -29,7 +29,6 @@ import ( "time" "github.com/CycloneDX/cyclonedx-go" - "github.com/google/go-github/v62/github" "github.com/google/uuid" "github.com/l3montree-dev/devguard/database/models" databasetypes "github.com/l3montree-dev/devguard/database/types" @@ -67,14 +66,7 @@ type scanService struct { utils.FireAndForgetSynchronizer } -var newGitHubClient = func() *github.Client { - return github.NewClient(&http.Client{ - Transport: utils.EgressTransport, - Timeout: 10 * time.Minute, - }) -} - -var downloadRawFileFn = DownloadRawFile +var downloadRawFileFn = DownloadGithubRepoAsZip var _ shared.ScanService = (*scanService)(nil) @@ -936,57 +928,51 @@ func (s *scanService) ScanSBOMWithoutSaving(ctx context.Context, bom *cyclonedx. }, nil } -func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetURL string) (vexReports []*normalize.VexReportOpenVEX, err error) { - client := newGitHubClient() +func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetURL string, targetBranch string) (vexReports []*normalize.VexReportOpenVEX, err error) { owner, repo, err := ParseGitHubURL(targetURL) if err != nil { return nil, err } // Determine default branch - repository, _, err := client.Repositories.Get(ctx, owner, repo) - if err != nil { - return nil, err - } - branch := repository.GetDefaultBranch() + branch := targetBranch if branch == "" { branch = "main" } - tree, _, err := client.Git.GetTree( - ctx, - owner, - repo, - branch, - true, // recursive - ) + resp, err := downloadRawFileFn(ctx, owner, repo, branch) if err != nil { - return nil, err } - for _, entry := range tree.Entries { - if entry.GetType() != "blob" { + + repoZip, err := utils.ZipReaderFromResponse(resp) + if err != nil { + return nil, fmt.Errorf("could not read obtained zip: %w", err) + } + + for _, fileEntry := range repoZip.File { + if fileEntry.FileInfo().IsDir() { continue } - filePath := entry.GetPath() - filename := strings.ToLower(path.Base(filePath)) + filename := strings.ToLower(path.Base(fileEntry.Name)) if !strings.HasSuffix(filename, ".json") { continue } - content, err := downloadRawFileFn( - ctx, - owner, - repo, - branch, - filePath, - ) + fileRead, err := fileEntry.Open() + if err != nil { + slog.Info("openvex document could not be opened, skipping this file for parsing", "filename", fileEntry.Name, "err", err) + continue + } + data, err := io.ReadAll(fileRead) + fileRead.Close() if err != nil { - slog.Info("download of openVEX failed", "err", err) + slog.Info("openvex document could not be opened, skipping this file for parsing", "filename", fileEntry.Name, "err", err) continue } + var openVEX ov.VEX - err = json.Unmarshal(content, &openVEX) + err = json.Unmarshal(data, &openVEX) if err != nil { slog.Info("could not unmarshal openVEX failed", "err", err) continue @@ -1006,42 +992,38 @@ func ParseGitHubURL(rawURL string) (owner string, repo string, err error) { return "", "", err } const githubDomain = "github.com" + const gitSuffix = ".git" + const trailingSlashSuffix = "/" if u.Host != githubDomain { return "", "", fmt.Errorf("invalid github repository url") } - parts := strings.Split(strings.Trim(u.Path, "/"), "/") + parts := strings.Split(strings.TrimSuffix(strings.Trim(u.Path, trailingSlashSuffix), gitSuffix), "/") if len(parts) < 2 { return "", "", fmt.Errorf("invalid github repository url path: expected /{owner}/{repo}, got %q", u.Path) } owner = parts[0] - repo = strings.TrimSuffix(parts[1], ".git") + repo = parts[1] if owner == "" || repo == "" { return "", "", fmt.Errorf("invalid github repository url path: expected non-empty owner and repo, got %q", u.Path) } return owner, repo, nil } -func DownloadRawFile(ctx context.Context, owner, repo, branch, filePath string) ([]byte, error) { - - rawURL := fmt.Sprintf( - "https://raw.githubusercontent.com/%s/%s/%s/%s", +func DownloadGithubRepoAsZip(ctx context.Context, owner, repo, branch string) (*http.Response, error) { + url := fmt.Sprintf( + "https://github.com/%s/%s/archive/refs/heads/%s.zip", owner, repo, branch, - filePath, ) - resp, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + resp, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } defer resp.Body.Close() switch resp.Response.StatusCode { case http.StatusOK: - file, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("401 Unauthorized") - } - return file, nil + return resp.Response, nil case http.StatusNotFound: return nil, fmt.Errorf("404 Source not found") case http.StatusUnauthorized: diff --git a/services/scan_service_test.go b/services/scan_service_test.go index 518f4c76f..55a220db7 100644 --- a/services/scan_service_test.go +++ b/services/scan_service_test.go @@ -15,15 +15,17 @@ package services import ( + "archive/zip" + "bytes" "context" "encoding/json" + "io" "net/http" "net/http/httptest" - "net/url" + "sort" "testing" "time" - "github.com/google/go-github/v62/github" "github.com/google/uuid" "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/dtos" @@ -296,62 +298,67 @@ func TestFetchSbomsFromUpstream_PassesURLNotRef(t *testing.T) { } func TestFetchOpenVexFromGitHub(t *testing.T) { - originalNewGitHubClient := newGitHubClient originalDownloadRawFileFn := downloadRawFileFn t.Cleanup(func() { - newGitHubClient = originalNewGitHubClient downloadRawFileFn = originalDownloadRawFileFn }) - t.Run("should fetch openvex reports from json files in the repository", func(t *testing.T) { - mockGitHub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/openvex-repo": - _, _ = w.Write([]byte(`{"default_branch":"main"}`)) - case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/openvex-repo/git/trees/main": - if got := r.URL.Query().Get("recursive"); got != "1" { - t.Fatalf("expected recursive=1, got %q", got) - } - _, _ = w.Write([]byte(`{"tree":[{"path":"reports/openvex.json","type":"blob"},{"path":"README.md","type":"blob"}]}`)) - default: - t.Fatalf("unexpected github api request: %s %s", r.Method, r.URL.String()) - } - })) - defer mockGitHub.Close() + newZipResponse := func(t *testing.T, files map[string]string) *http.Response { + t.Helper() - newGitHubClient = func() *github.Client { - client := github.NewClient(mockGitHub.Client()) - baseURL, err := url.Parse(mockGitHub.URL + "/") + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + paths := make([]string, 0, len(files)) + for filePath := range files { + paths = append(paths, filePath) + } + sort.Strings(paths) + for _, filePath := range paths { + content := files[filePath] + entry, err := zw.Create(filePath) if err != nil { - t.Fatalf("failed to parse mock github url: %v", err) + t.Fatalf("failed to create zip entry %s: %v", filePath, err) + } + if _, err := entry.Write([]byte(content)); err != nil { + t.Fatalf("failed to write zip entry %s: %v", filePath, err) } - client.BaseURL = baseURL - client.UploadURL = baseURL - return client + } + if err := zw.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(buf.Bytes())), + } + } + + t.Run("should fetch openvex reports from json files in the repository", func(t *testing.T) { calls := 0 - downloadRawFileFn = func(ctx context.Context, owner, repo, branch, filePath string) ([]byte, error) { + downloadRawFileFn = func(ctx context.Context, owner, repo, branch string) (*http.Response, error) { calls++ assert.Equal(t, "octo-org", owner) assert.Equal(t, "openvex-repo", repo) assert.Equal(t, "main", branch) - assert.Equal(t, "reports/openvex.json", filePath) ts := time.Date(2026, time.May, 20, 12, 0, 0, 0, time.UTC) - payload := map[string]any{ - "@context": "https://openvex.dev/ns/v0.2.0", - "@id": "openvex-1", - "author": "test-author", - "timestamp": ts, - "version": 1, - "statements": []any{}, - } - return json.Marshal(payload) + return newZipResponse(t, map[string]string{ + "reports/openvex.json": mustMarshalJSON(t, map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "openvex-1", + "author": "test-author", + "timestamp": ts, + "version": 1, + "statements": []any{}, + }), + "README.md": "# ignore me", + }), nil } service := &scanService{} - reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://github.com/octo-org/openvex-repo") + reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://github.com/octo-org/openvex-repo", "") assert.NoError(t, err) assert.Len(t, reports, 1) assert.Equal(t, "https://github.com/octo-org/openvex-repo", reports[0].Source) @@ -362,65 +369,37 @@ func TestFetchOpenVexFromGitHub(t *testing.T) { }) t.Run("should fetch multiple openvex reports from multiple json files", func(t *testing.T) { - mockGitHub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/multi-vex-repo": - _, _ = w.Write([]byte(`{"default_branch":"develop"}`)) - case r.Method == http.MethodGet && r.URL.Path == "/repos/octo-org/multi-vex-repo/git/trees/develop": - if got := r.URL.Query().Get("recursive"); got != "1" { - t.Fatalf("expected recursive=1, got %q", got) - } - _, _ = w.Write([]byte(`{"tree":[{"path":"vex/vex1.json","type":"blob"},{"path":"vex/vex2.json","type":"blob"},{"path":"README.md","type":"blob"}]}`)) - default: - t.Fatalf("unexpected github api request: %s %s", r.Method, r.URL.String()) - } - })) - defer mockGitHub.Close() - - newGitHubClient = func() *github.Client { - client := github.NewClient(mockGitHub.Client()) - baseURL, err := url.Parse(mockGitHub.URL + "/") - if err != nil { - t.Fatalf("failed to parse mock github url: %v", err) - } - client.BaseURL = baseURL - client.UploadURL = baseURL - return client - } - calls := 0 - downloadRawFileFn = func(ctx context.Context, owner, repo, branch, filePath string) ([]byte, error) { + downloadRawFileFn = func(ctx context.Context, owner, repo, branch string) (*http.Response, error) { calls++ assert.Equal(t, "octo-org", owner) assert.Equal(t, "multi-vex-repo", repo) assert.Equal(t, "develop", branch) ts := time.Date(2026, time.May, 20, 12, 0, 0, 0, time.UTC) - var id, author string - switch filePath { - case "vex/vex1.json": - id = "openvex-first" - author = "author-one" - case "vex/vex2.json": - id = "openvex-second" - author = "author-two" - default: - t.Fatalf("unexpected file path: %s", filePath) - } - - payload := map[string]any{ - "@context": "https://openvex.dev/ns/v0.2.0", - "@id": id, - "author": author, - "timestamp": ts, - "version": 1, - "statements": []any{}, - } - return json.Marshal(payload) + return newZipResponse(t, map[string]string{ + "vex/vex1.json": mustMarshalJSON(t, map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "openvex-first", + "author": "author-one", + "timestamp": ts, + "version": 1, + "statements": []any{}, + }), + "vex/vex2.json": mustMarshalJSON(t, map[string]any{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "openvex-second", + "author": "author-two", + "timestamp": ts, + "version": 1, + "statements": []any{}, + }), + "README.md": "# ignore me", + }), nil } service := &scanService{} - reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://github.com/octo-org/multi-vex-repo") + reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://github.com/octo-org/multi-vex-repo", "develop") assert.NoError(t, err) assert.Len(t, reports, 2) assert.Equal(t, "https://github.com/octo-org/multi-vex-repo", reports[0].Source) @@ -429,14 +408,23 @@ func TestFetchOpenVexFromGitHub(t *testing.T) { assert.Equal(t, "openvex-second", reports[1].Report.ID) assert.Equal(t, "author-one", reports[0].Report.Author) assert.Equal(t, "author-two", reports[1].Report.Author) - assert.Equal(t, 2, calls) + assert.Equal(t, 1, calls) }) t.Run("should reject non github urls", func(t *testing.T) { service := &scanService{} - reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://example.com/repo") + reports, err := service.FetchOpenVexFromGitHub(context.Background(), "https://example.com/repo", "") assert.Error(t, err) assert.Nil(t, reports) assert.Contains(t, err.Error(), "invalid github repository url") }) } + +func mustMarshalJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("failed to marshal json: %v", err) + } + return string(data) +} From bdbbdc81a50db6331674b5bae99941573497720d Mon Sep 17 00:00:00 2001 From: Dennis Tuan Anh Quach <83472928+Dboy0ZDev@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:23:44 +0200 Subject: [PATCH 07/10] Implemented daemon for openvex fetching from static sources and system vexrule creation --- daemons/daemon.go | 6 + daemons/openvex_daemon.go | 43 +++++ ...20260601085124_add_system_vex_rules.up.sql | 36 ++++ database/models/system_vex_rule_model.go | 56 ++++++ database/models/vex_rule_model.go | 4 + database/repositories/providers.go | 1 + .../repositories/system_vexrule_repository.go | 39 +++++ mocks/mock_ScanService.go | 74 ++++++++ mocks/mock_SystemVEXRuleRepository.go | 162 ++++++++++++++++++ mocks/mock_VEXRuleService.go | 57 ++++++ services/scan_service.go | 35 ++-- services/vex_rule_service.go | 97 ++++++++++- services/vex_rule_service_test.go | 60 +++++-- shared/common_interfaces.go | 7 + tests/vex_test.go | 36 +++- transformer/vex_rule_transformer.go | 19 ++ 16 files changed, 694 insertions(+), 38 deletions(-) create mode 100644 daemons/openvex_daemon.go create mode 100644 database/migrations/20260601085124_add_system_vex_rules.up.sql create mode 100644 database/models/system_vex_rule_model.go create mode 100644 database/repositories/system_vexrule_repository.go create mode 100644 mocks/mock_SystemVEXRuleRepository.go diff --git a/daemons/daemon.go b/daemons/daemon.go index 6f2b1cd50..e848c8a0c 100644 --- a/daemons/daemon.go +++ b/daemons/daemon.go @@ -101,4 +101,10 @@ func (runner *DaemonRunner) runDaemons(ctx context.Context) { }); err != nil { slog.Error("could not resolve direct depend ency fixed versions", "err", err) } + + if err := runner.maybeRunAndMark(ctx, "openvex.updateSystemVEXRules", func() error { + return runner.UpdateSystemVEXRulesFromOpenVEXSources(ctx) + }); err != nil { + slog.Error("could not update system vex rules from openvex sources", "err", err) + } } diff --git a/daemons/openvex_daemon.go b/daemons/openvex_daemon.go new file mode 100644 index 000000000..26c53cfab --- /dev/null +++ b/daemons/openvex_daemon.go @@ -0,0 +1,43 @@ +package daemons + +import ( + "context" + "log/slog" + "os" + "strings" + + "github.com/l3montree-dev/devguard/normalize" +) + +func (runner *DaemonRunner) UpdateSystemVEXRulesFromOpenVEXSources(ctx context.Context) error { + enviromentSources := os.Getenv("OPENVEX_SOURCES") + if enviromentSources == "" { + slog.Info("no OpenVEX sources set in env variables, skipped fetching OpenVEX from static sources") + return nil + } + staticOpenVEXSources := strings.Split(os.Getenv("OPENVEX_SOURCES"), ",") + if len(staticOpenVEXSources) == 0 { + slog.Info("no OpenVEX sources set in env variables, skipped fetching OpenVEX from static sources") + return nil + } + + slog.Info("fetching OpenVEX from static sources") + var results []*normalize.VexReportOpenVEX + for _, source := range staticOpenVEXSources { + reports, err := runner.scanService.FetchOpenVexFromGitHub(ctx, source, "main") + if err != nil { + slog.Error("failed to fetch OpenVEX report from static source", "source", source, "error", err) + continue + } + results = append(results, reports...) + } + + err := runner.vexRuleService.UpdateSystemVEXRulesFromStaticSources(ctx, results) + + if err != nil { + slog.Error("failed to update VEX rules from static sources", "error", err) + return err + } + + return nil +} diff --git a/database/migrations/20260601085124_add_system_vex_rules.up.sql b/database/migrations/20260601085124_add_system_vex_rules.up.sql new file mode 100644 index 000000000..edc20dceb --- /dev/null +++ b/database/migrations/20260601085124_add_system_vex_rules.up.sql @@ -0,0 +1,36 @@ +-- Copyright (C) 2026 l3montree GmbH +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as +-- published by the Free Software Foundation, either version 3 of the +-- License, or (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Create system_vex_rules table +CREATE TABLE IF NOT EXISTS public.system_vex_rules ( + id TEXT PRIMARY KEY, + cve_id TEXT NOT NULL, + justification TEXT NOT NULL, + mechanical_justification TEXT, + path_pattern JSONB NOT NULL, + vex_source TEXT NOT NULL DEFAULT '', + event_type TEXT NOT NULL DEFAULT 'falsePositive', + created_by_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_system_vex_rules_cve + FOREIGN KEY (cve_id) + REFERENCES public.cves(cve) + ON DELETE CASCADE +); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_vex_rule_cve ON public.system_vex_rules(cve_id); +CREATE INDEX IF NOT EXISTS idx_vex_rules_composite ON public.system_vex_rules (cve_id, vex_source); \ No newline at end of file diff --git a/database/models/system_vex_rule_model.go b/database/models/system_vex_rule_model.go new file mode 100644 index 000000000..37a96d3dd --- /dev/null +++ b/database/models/system_vex_rule_model.go @@ -0,0 +1,56 @@ +package models + +import ( + "fmt" + "strings" + "time" + + "github.com/l3montree-dev/devguard/dtos" + "github.com/l3montree-dev/devguard/utils" +) + +type SystemVEXRule struct { + // Single primary key - hash of composite components + ID string `json:"id" gorm:"primaryKey;not null;"` + + // Composite key components (for indexing and queries) + CVEID string `json:"cveId" gorm:"type:text;not null;index:,composite:vex_composite_key"` + VexSource string `json:"vexSource" gorm:"type:text;not null;index:,composite:vex_composite_key"` + + // Timestamps + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + + // Relationships# + CVE *CVE `json:"cve" gorm:"foreignKey:CVEID;references:CVE;"` + + // Rule data + Justification string `json:"justification" gorm:"type:text;not null"` + MechanicalJustification dtos.MechanicalJustificationType `json:"mechanicalJustification" gorm:"type:text;"` + EventType dtos.VulnEventType `json:"eventType" gorm:"type:text;not null;"` + + // PathPattern stores the path patterns for this VEX rule. + // Supports wildcards: "*" matches any element. + PathPattern []string `json:"pathPattern" gorm:"type:jsonb;not null;serializer:json"` + CreatedByID string `json:"createdById" gorm:"type:text;not null"` +} + +// CalculateID computes a SHA256 hash of CVEID, PathPattern, and VexSource for use as the primary key. +// This ensures a deterministic, unique ID for each VEX rule combination. +func CalculateSystemVEXRuleID(cveID string, pathPattern []string, vexSource string) string { + data := fmt.Sprintf("%s/%s/%s", cveID, strings.Join(pathPattern, ","), vexSource) + return utils.HashString(data) +} + +// SetPathPattern sets the PathPattern and recalculates the ID. +func (r *SystemVEXRule) SetPathPattern(pattern []string) { + r.PathPattern = pattern + r.ID = CalculateSystemVEXRuleID(r.CVEID, pattern, r.VexSource) +} + +// EnsureID calculates the ID if it hasn't been set yet. +func (r *SystemVEXRule) EnsureID() { + if r.ID == "" { + r.ID = CalculateSystemVEXRuleID(r.CVEID, r.PathPattern, r.VexSource) + } +} diff --git a/database/models/vex_rule_model.go b/database/models/vex_rule_model.go index ad1ec5091..c1ec37b06 100644 --- a/database/models/vex_rule_model.go +++ b/database/models/vex_rule_model.go @@ -62,6 +62,10 @@ type VEXRule struct { // When false, the rule exists but does not create events or modify vulnerability state. // Rules are disabled when uploaded in ParanoidMode, requiring manual review/enabling. Enabled bool `json:"enabled" gorm:"default:true;not null;"` + + // IsSystemVEXRule is used to indicate if the VEXRule was created/imported into the system + // with not relation to any other Asset + IsSystemVEXRule bool `json:"isSystemVEXRule" gorm:"default:false;not null;"` } func (VEXRule) TableName() string { diff --git a/database/repositories/providers.go b/database/repositories/providers.go index 84d540a31..442823b1a 100644 --- a/database/repositories/providers.go +++ b/database/repositories/providers.go @@ -55,6 +55,7 @@ var Module = fx.Options( fx.Provide(fx.Annotate(NewJiraIntegrationRepository, fx.As(new(shared.JiraIntegrationRepository)))), fx.Provide(fx.Annotate(NewCveRelationshipRepository, fx.As(new(shared.CVERelationshipRepository)))), fx.Provide(fx.Annotate(NewVEXRuleRepository, fx.As(new(shared.VEXRuleRepository)))), + fx.Provide(fx.Annotate(NewSystemVEXRuleRepository, fx.As(new(shared.SystemVEXRuleRepository)))), fx.Provide(fx.Annotate(NewExternalReferenceRepository, fx.As(new(shared.ExternalReferenceRepository)))), fx.Provide(fx.Annotate(NewTrustedEntityRepository, fx.As(new(shared.TrustedEntityRepository)))), fx.Provide(fx.Annotate(NewDependencyProxyRepository, fx.As(new(shared.DependencyProxySecretRepository)))), diff --git a/database/repositories/system_vexrule_repository.go b/database/repositories/system_vexrule_repository.go new file mode 100644 index 000000000..5380f92b9 --- /dev/null +++ b/database/repositories/system_vexrule_repository.go @@ -0,0 +1,39 @@ +package repositories + +import ( + "context" + + "github.com/l3montree-dev/devguard/database/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type systemVEXRuleRepository struct { + db *gorm.DB +} + +func NewSystemVEXRuleRepository(db *gorm.DB) *systemVEXRuleRepository { + return &systemVEXRuleRepository{ + db: db, + } +} + +func (r *systemVEXRuleRepository) GetDB(ctx context.Context, tx *gorm.DB) *gorm.DB { + if tx != nil { + return tx + } + return r.db.WithContext(ctx) +} + +func (r *systemVEXRuleRepository) UpsertBatch(ctx context.Context, tx *gorm.DB, rules []models.SystemVEXRule) error { + if len(rules) == 0 { + return nil + } + // Ensure IDs are calculated + for i := range rules { + rules[i].EnsureID() + } + return r.GetDB(ctx, tx).Clauses(clause.OnConflict{ + UpdateAll: true, + }).CreateInBatches(&rules, 1000).Error +} diff --git a/mocks/mock_ScanService.go b/mocks/mock_ScanService.go index 3bfb54272..ea10dae34 100644 --- a/mocks/mock_ScanService.go +++ b/mocks/mock_ScanService.go @@ -43,6 +43,80 @@ func (_m *ScanService) EXPECT() *ScanService_Expecter { return &ScanService_Expecter{mock: &_m.Mock} } +// FetchOpenVexFromGitHub provides a mock function for the type ScanService +func (_mock *ScanService) FetchOpenVexFromGitHub(ctx context.Context, targetURL string, targetBranch string) ([]*normalize.VexReportOpenVEX, error) { + ret := _mock.Called(ctx, targetURL, targetBranch) + + if len(ret) == 0 { + panic("no return value specified for FetchOpenVexFromGitHub") + } + + var r0 []*normalize.VexReportOpenVEX + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]*normalize.VexReportOpenVEX, error)); ok { + return returnFunc(ctx, targetURL, targetBranch) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []*normalize.VexReportOpenVEX); ok { + r0 = returnFunc(ctx, targetURL, targetBranch) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*normalize.VexReportOpenVEX) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, targetURL, targetBranch) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ScanService_FetchOpenVexFromGitHub_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FetchOpenVexFromGitHub' +type ScanService_FetchOpenVexFromGitHub_Call struct { + *mock.Call +} + +// FetchOpenVexFromGitHub is a helper method to define mock.On call +// - ctx context.Context +// - targetURL string +// - targetBranch string +func (_e *ScanService_Expecter) FetchOpenVexFromGitHub(ctx interface{}, targetURL interface{}, targetBranch interface{}) *ScanService_FetchOpenVexFromGitHub_Call { + return &ScanService_FetchOpenVexFromGitHub_Call{Call: _e.mock.On("FetchOpenVexFromGitHub", ctx, targetURL, targetBranch)} +} + +func (_c *ScanService_FetchOpenVexFromGitHub_Call) Run(run func(ctx context.Context, targetURL string, targetBranch string)) *ScanService_FetchOpenVexFromGitHub_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ScanService_FetchOpenVexFromGitHub_Call) Return(vexReports []*normalize.VexReportOpenVEX, err error) *ScanService_FetchOpenVexFromGitHub_Call { + _c.Call.Return(vexReports, err) + return _c +} + +func (_c *ScanService_FetchOpenVexFromGitHub_Call) RunAndReturn(run func(ctx context.Context, targetURL string, targetBranch string) ([]*normalize.VexReportOpenVEX, error)) *ScanService_FetchOpenVexFromGitHub_Call { + _c.Call.Return(run) + return _c +} + // FetchSbomsFromUpstream provides a mock function for the type ScanService func (_mock *ScanService) FetchSbomsFromUpstream(ctx context.Context, artifactName string, ref string, upstreamURLs []string, keepOriginalSbomRootComponent bool) ([]*normalize.SBOMGraph, []string, []dtos.ExternalReferenceError) { ret := _mock.Called(ctx, artifactName, ref, upstreamURLs, keepOriginalSbomRootComponent) diff --git a/mocks/mock_SystemVEXRuleRepository.go b/mocks/mock_SystemVEXRuleRepository.go new file mode 100644 index 000000000..33fa63073 --- /dev/null +++ b/mocks/mock_SystemVEXRuleRepository.go @@ -0,0 +1,162 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/shared" + mock "github.com/stretchr/testify/mock" +) + +// NewSystemVEXRuleRepository creates a new instance of SystemVEXRuleRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewSystemVEXRuleRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *SystemVEXRuleRepository { + mock := &SystemVEXRuleRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// SystemVEXRuleRepository is an autogenerated mock type for the SystemVEXRuleRepository type +type SystemVEXRuleRepository struct { + mock.Mock +} + +type SystemVEXRuleRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *SystemVEXRuleRepository) EXPECT() *SystemVEXRuleRepository_Expecter { + return &SystemVEXRuleRepository_Expecter{mock: &_m.Mock} +} + +// GetDB provides a mock function for the type SystemVEXRuleRepository +func (_mock *SystemVEXRuleRepository) GetDB(ctx context.Context, db shared.DB) shared.DB { + ret := _mock.Called(ctx, db) + + if len(ret) == 0 { + panic("no return value specified for GetDB") + } + + var r0 shared.DB + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB) shared.DB); ok { + r0 = returnFunc(ctx, db) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(shared.DB) + } + } + return r0 +} + +// SystemVEXRuleRepository_GetDB_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDB' +type SystemVEXRuleRepository_GetDB_Call struct { + *mock.Call +} + +// GetDB is a helper method to define mock.On call +// - ctx context.Context +// - db shared.DB +func (_e *SystemVEXRuleRepository_Expecter) GetDB(ctx interface{}, db interface{}) *SystemVEXRuleRepository_GetDB_Call { + return &SystemVEXRuleRepository_GetDB_Call{Call: _e.mock.On("GetDB", ctx, db)} +} + +func (_c *SystemVEXRuleRepository_GetDB_Call) Run(run func(ctx context.Context, db shared.DB)) *SystemVEXRuleRepository_GetDB_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *SystemVEXRuleRepository_GetDB_Call) Return(v shared.DB) *SystemVEXRuleRepository_GetDB_Call { + _c.Call.Return(v) + return _c +} + +func (_c *SystemVEXRuleRepository_GetDB_Call) RunAndReturn(run func(ctx context.Context, db shared.DB) shared.DB) *SystemVEXRuleRepository_GetDB_Call { + _c.Call.Return(run) + return _c +} + +// UpsertBatch provides a mock function for the type SystemVEXRuleRepository +func (_mock *SystemVEXRuleRepository) UpsertBatch(ctx context.Context, tx shared.DB, rules []models.SystemVEXRule) error { + ret := _mock.Called(ctx, tx, rules) + + if len(ret) == 0 { + panic("no return value specified for UpsertBatch") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []models.SystemVEXRule) error); ok { + r0 = returnFunc(ctx, tx, rules) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// SystemVEXRuleRepository_UpsertBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertBatch' +type SystemVEXRuleRepository_UpsertBatch_Call struct { + *mock.Call +} + +// UpsertBatch is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - rules []models.SystemVEXRule +func (_e *SystemVEXRuleRepository_Expecter) UpsertBatch(ctx interface{}, tx interface{}, rules interface{}) *SystemVEXRuleRepository_UpsertBatch_Call { + return &SystemVEXRuleRepository_UpsertBatch_Call{Call: _e.mock.On("UpsertBatch", ctx, tx, rules)} +} + +func (_c *SystemVEXRuleRepository_UpsertBatch_Call) Run(run func(ctx context.Context, tx shared.DB, rules []models.SystemVEXRule)) *SystemVEXRuleRepository_UpsertBatch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []models.SystemVEXRule + if args[2] != nil { + arg2 = args[2].([]models.SystemVEXRule) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *SystemVEXRuleRepository_UpsertBatch_Call) Return(err error) *SystemVEXRuleRepository_UpsertBatch_Call { + _c.Call.Return(err) + return _c +} + +func (_c *SystemVEXRuleRepository_UpsertBatch_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, rules []models.SystemVEXRule) error) *SystemVEXRuleRepository_UpsertBatch_Call { + _c.Call.Return(run) + return _c +} diff --git a/mocks/mock_VEXRuleService.go b/mocks/mock_VEXRuleService.go index 5133e4fa8..0e743782c 100644 --- a/mocks/mock_VEXRuleService.go +++ b/mocks/mock_VEXRuleService.go @@ -1381,3 +1381,60 @@ func (_c *VEXRuleService_Update_Call) RunAndReturn(run func(ctx context.Context, _c.Call.Return(run) return _c } + +// UpdateSystemVEXRulesFromStaticSources provides a mock function for the type VEXRuleService +func (_mock *VEXRuleService) UpdateSystemVEXRulesFromStaticSources(ctx context.Context, reports []*normalize.VexReportOpenVEX) error { + ret := _mock.Called(ctx, reports) + + if len(ret) == 0 { + panic("no return value specified for UpdateSystemVEXRulesFromStaticSources") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, []*normalize.VexReportOpenVEX) error); ok { + r0 = returnFunc(ctx, reports) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateSystemVEXRulesFromStaticSources' +type VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call struct { + *mock.Call +} + +// UpdateSystemVEXRulesFromStaticSources is a helper method to define mock.On call +// - ctx context.Context +// - reports []*normalize.VexReportOpenVEX +func (_e *VEXRuleService_Expecter) UpdateSystemVEXRulesFromStaticSources(ctx interface{}, reports interface{}) *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call { + return &VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call{Call: _e.mock.On("UpdateSystemVEXRulesFromStaticSources", ctx, reports)} +} + +func (_c *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call) Run(run func(ctx context.Context, reports []*normalize.VexReportOpenVEX)) *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []*normalize.VexReportOpenVEX + if args[1] != nil { + arg1 = args[1].([]*normalize.VexReportOpenVEX) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call) Return(err error) *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call { + _c.Call.Return(err) + return _c +} + +func (_c *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call) RunAndReturn(run func(ctx context.Context, reports []*normalize.VexReportOpenVEX) error) *VEXRuleService_UpdateSystemVEXRulesFromStaticSources_Call { + _c.Call.Return(run) + return _c +} diff --git a/services/scan_service.go b/services/scan_service.go index a31c42fc8..98f0c235c 100644 --- a/services/scan_service.go +++ b/services/scan_service.go @@ -949,6 +949,7 @@ func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetURL stri if err != nil { return nil, fmt.Errorf("could not read obtained zip: %w", err) } + defer resp.Body.Close() for _, fileEntry := range repoZip.File { if fileEntry.FileInfo().IsDir() { @@ -971,17 +972,23 @@ func (s *scanService) FetchOpenVexFromGitHub(ctx context.Context, targetURL stri continue } + if !json.Valid(data) { + slog.Info("skipping non-JSON file in OpenVEX repo", "filename", fileEntry.Name) + continue + } + var openVEX ov.VEX err = json.Unmarshal(data, &openVEX) if err != nil { - slog.Info("could not unmarshal openVEX failed", "err", err) + slog.Info("could not unmarshal openVEX failed", "err", err, "filename", filename) continue } - - vexReports = append(vexReports, &normalize.VexReportOpenVEX{ - Report: &openVEX, - Source: targetURL, - }) + newVexReport, err := normalize.NewVexReportOpenVEX(&openVEX, targetURL) + if err != nil { + slog.Info("could not create openVEX report structure", "err", err, "filename", filename) + continue + } + vexReports = append(vexReports, newVexReport) } return vexReports, nil } @@ -1016,14 +1023,20 @@ func DownloadGithubRepoAsZip(ctx context.Context, owner, repo, branch string) (* repo, branch, ) - resp, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { return nil, err } - defer resp.Body.Close() - switch resp.Response.StatusCode { + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + switch resp.StatusCode { case http.StatusOK: - return resp.Response, nil + return resp, nil case http.StatusNotFound: return nil, fmt.Errorf("404 Source not found") case http.StatusUnauthorized: @@ -1031,6 +1044,6 @@ func DownloadGithubRepoAsZip(ctx context.Context, owner, repo, branch string) (* case http.StatusInternalServerError: return nil, fmt.Errorf("500 Internal Server error") default: - return nil, fmt.Errorf("Unexpected status: %d\n", resp.Response.StatusCode) + return nil, fmt.Errorf("Unexpected status: %d\n", resp.StatusCode) } } diff --git a/services/vex_rule_service.go b/services/vex_rule_service.go index a8f959c23..b44b89219 100644 --- a/services/vex_rule_service.go +++ b/services/vex_rule_service.go @@ -33,26 +33,33 @@ import ( "github.com/l3montree-dev/devguard/normalize" "github.com/l3montree-dev/devguard/shared" "github.com/l3montree-dev/devguard/statemachine" + "github.com/l3montree-dev/devguard/transformer" "github.com/l3montree-dev/devguard/utils" ) type VEXRuleService struct { vexRuleRepository shared.VEXRuleRepository + systemVEXRuleRepository shared.SystemVEXRuleRepository dependencyVulnRepository shared.DependencyVulnRepository vulnEventRepository shared.VulnEventRepository + cveRepository shared.CveRepository } var _ shared.VEXRuleService = (*VEXRuleService)(nil) func NewVEXRuleService( vexRuleRepository shared.VEXRuleRepository, + systemVEXRuleRepository shared.SystemVEXRuleRepository, dependencyVulnRepository shared.DependencyVulnRepository, vulnEventRepository shared.VulnEventRepository, + cveRepository shared.CveRepository, ) *VEXRuleService { return &VEXRuleService{ vexRuleRepository: vexRuleRepository, + systemVEXRuleRepository: systemVEXRuleRepository, dependencyVulnRepository: dependencyVulnRepository, vulnEventRepository: vulnEventRepository, + cveRepository: cveRepository, } } @@ -527,8 +534,8 @@ func (s *VEXRuleService) parseVEXRulesFromOpenVEXReport(ctx context.Context, ass rules := make([]models.VEXRule, 0, len(vex.Statements)) for _, statement := range vex.Statements { - if statement.ID == "" { - slog.Info("statement does not contain ID, skipping component for VEX rule creation", "openVEXReport", vex.ID) + if statement.Status == "" { + slog.Info("statement does not status, skipping component for VEX rule creation", "openVEXReport", vex.ID) continue } cveID := string(statement.Vulnerability.Name) @@ -784,3 +791,89 @@ func matchRulesToVulns(rules []models.VEXRule, vulns []models.DependencyVuln) ma } return result } + +func (s *VEXRuleService) UpdateSystemVEXRulesFromStaticSources(ctx context.Context, reports []*normalize.VexReportOpenVEX) error { + systemVEXRulesMap := make(map[string]bool) + var systemVEXRules []models.SystemVEXRule + includedCVEsMap := make(map[string]bool) + var includedCVEs []string + + for _, report := range reports { + if report.Source == "" { + slog.Info("OpenVEX report contains no source. Skipping this report") + continue + } + if report.Report == nil { + slog.Info("OpenVEX report contains no report information. Skipping this report") + continue + } + + parsedVEXRules, err := s.parseVEXRulesFromOpenVEXReport(ctx, uuid.Nil, "main", report) + if err != nil { + slog.Info("Error while parsing OpenVEX report", "error", err, "report", report.Report.ID) + continue + } + for _, parsedRule := range parsedVEXRules { + // This clause uses a map for deduplication + if _, exists := systemVEXRulesMap[parsedRule.ID]; !exists { + systemVEXRule := transformer.VEXRuleToSystemVEXRule(parsedRule) + systemVEXRulesMap[parsedRule.ID] = true + systemVEXRules = append(systemVEXRules, systemVEXRule) + if _, cveExists := includedCVEsMap[parsedRule.CVEID]; !cveExists { + includedCVEs = append(includedCVEs, parsedRule.CVEID) + } + } + } + } + //Check if CVEs are already in database since database can take some time to be established + // If there are a lot of CVEs in a project, the lookup might fail for having + // more than 65535 keys + const cveBatchSize = 1000 + + existingCVEMap := make(map[string]models.CVE) + + for start := 0; start < len(includedCVEs); start += cveBatchSize { + end := start + cveBatchSize + if end > len(includedCVEs) { + end = len(includedCVEs) + } + + batch := includedCVEs[start:end] + found, err := s.cveRepository.FindCVEs(ctx, nil, batch) + if err != nil { + return fmt.Errorf("failed to fetch existing CVEs: %w", err) + } + + for _, cve := range found { + existingCVEMap[strings.ToLower(strings.TrimSpace(cve.CVE))] = cve + } + } + + filteredRules := make([]models.SystemVEXRule, 0, len(systemVEXRules)) + for _, rule := range systemVEXRules { + cveKey := strings.ToLower(strings.TrimSpace(rule.CVEID)) + if _, exists := existingCVEMap[cveKey]; !exists { + slog.Info("skipping system VEX rule because CVE does not exist in database yet", + "cveID", rule.CVEID, + "vexSource", rule.VexSource, + "ruleID", rule.ID, + ) + continue + } + filteredRules = append(filteredRules, rule) + } + + if len(filteredRules) == 0 { + slog.Info("no system VEX rules left after CVE filtering") + return nil + } + + //Bulk Upload of valid VEXRules + err := s.systemVEXRuleRepository.UpsertBatch(ctx, nil, filteredRules) + if err != nil { + return fmt.Errorf("Error while inserting extracted VEXRules into database: %s", err) + } + slog.Info("updated system VEXRules", "fetched", len(systemVEXRules), "filtered", len(filteredRules)) + + return nil +} diff --git a/services/vex_rule_service_test.go b/services/vex_rule_service_test.go index 1764c59e8..7d96d0544 100644 --- a/services/vex_rule_service_test.go +++ b/services/vex_rule_service_test.go @@ -217,10 +217,12 @@ func TestVEXRuleServiceUpdate(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.Update(context.Background(), nil, rule) assert.NoError(t, err) @@ -240,12 +242,14 @@ func TestVEXRuleServiceDelete(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("Delete", mock.Anything, mock.Anything, mock.MatchedBy(func(r models.VEXRule) bool { return r.ID == "test-rule-1" })).Return(nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.Delete(context.Background(), nil, rule) assert.NoError(t, err) @@ -259,10 +263,12 @@ func TestVEXRuleServiceDeleteByAssetVersion(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("DeleteByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.DeleteByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -290,10 +296,12 @@ func TestVEXRuleServiceFindByAssetVersion(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("FindByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(rules, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) found, err := service.FindByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -316,10 +324,12 @@ func TestVEXRuleServiceFindByID(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("FindByID", mock.Anything, mock.Anything, "test-rule-1").Return(rule, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) found, err := service.FindByID(context.Background(), nil, "test-rule-1") assert.NoError(t, err) @@ -368,6 +378,8 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -377,7 +389,7 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) counts, err := service.CountMatchingVulnsForRules(context.Background(), nil, rules) assert.NoError(t, err) @@ -418,6 +430,8 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -427,7 +441,7 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) count, err := service.CountMatchingVulns(context.Background(), nil, rule) assert.NoError(t, err) @@ -469,6 +483,8 @@ func TestVEXRuleEnabledBasedOnParanoidMode(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) // Mock FindByAssetAndVexSource to return empty (no existing rules) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -482,7 +498,7 @@ func TestVEXRuleEnabledBasedOnParanoidMode(t *testing.T) { // Mock GetAllOpenVulnsByAssetVersionNameAndAssetID for ApplyRulesToExistingVulns depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) // Create a minimal VEX report with one vulnerability vexReport := createTestVexReport() @@ -649,6 +665,8 @@ func TestApplyRulesToExistingVulnsOnlyAppliesEnabledRules(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) // Mock GetAllOpenVulnsByAssetVersionNameAndAssetID to return both vulns depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, assetVersionName, assetID). @@ -666,7 +684,7 @@ func TestApplyRulesToExistingVulnsOnlyAppliesEnabledRules(t *testing.T) { savedEvents = args.Get(2).([]models.VulnEvent) }).Return(nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) // Apply both rules (one enabled, one disabled) _, err := service.ApplyRulesToExistingVulns(context.Background(), nil, []models.VEXRule{enabledRule, disabledRule}) @@ -719,8 +737,10 @@ func TestEnablingRuleAppliesItToVulns(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) // First, try to apply the disabled rule - should not save any events depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, assetVersionName, assetID). @@ -804,6 +824,8 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -814,7 +836,7 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.IngestVEX(context.Background(), nil, asset, assetVersion, vexReport) assert.NoError(t, err) @@ -849,7 +871,7 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { func TestParseVEXRulesFromOpenVEXReport_SelectValidProductID(t *testing.T) { assetID := uuid.New() - service := NewVEXRuleService(nil, nil, nil) + service := NewVEXRuleService(nil, nil, nil, nil, nil) testCases := []struct { name string @@ -927,7 +949,7 @@ func TestParseVEXRulesFromOpenVEXReport_SelectValidProductID(t *testing.T) { // VEX rule per statement. func TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements(t *testing.T) { assetID := uuid.New() - service := NewVEXRuleService(nil, nil, nil) + service := NewVEXRuleService(nil, nil, nil, nil, nil) ts := time.Now().UTC() report := &normalize.VexReportOpenVEX{ @@ -1280,6 +1302,8 @@ func TestParseVEXRulesInBOM_PathPatternFromProperties(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -1290,7 +1314,7 @@ func TestParseVEXRulesInBOM_PathPatternFromProperties(t *testing.T) { depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.IngestVEX(context.Background(), nil, asset, assetVersion, vexReport) assert.NoError(t, err) @@ -1358,6 +1382,8 @@ func TestParseVEXRulesInBOM_MultiplePathPatternProperties(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -1368,7 +1394,7 @@ func TestParseVEXRulesInBOM_MultiplePathPatternProperties(t *testing.T) { depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.IngestVEX(context.Background(), nil, asset, assetVersion, vexReport) assert.NoError(t, err) @@ -1400,10 +1426,12 @@ func TestVEXRuleServiceCreate(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.Create(context.Background(), nil, rule) assert.NoError(t, err) diff --git a/shared/common_interfaces.go b/shared/common_interfaces.go index c6ff223a3..6e6e1388a 100644 --- a/shared/common_interfaces.go +++ b/shared/common_interfaces.go @@ -339,6 +339,11 @@ type VEXRuleRepository interface { FindByAssetVersionAndCVE(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, cveID string) ([]models.VEXRule, error) } +type SystemVEXRuleRepository interface { + GetDB(ctx context.Context, db DB) DB + UpsertBatch(ctx context.Context, tx DB, rules []models.SystemVEXRule) error +} + type OrganizationRepository interface { utils.Repository[uuid.UUID, models.Org, DB] ReadBySlug(ctx context.Context, tx DB, slug string) (models.Org, error) @@ -468,6 +473,7 @@ type ScanService interface { RunArtifactSecurityLifecycle(ctx context.Context, tx DB, org models.Org, project models.Project, asset models.Asset, assetVersion models.AssetVersion, artifact models.Artifact, userID string, userAgent *string) (*normalize.SBOMGraph, []*normalize.VexReport, []models.DependencyVuln, error) ScanSBOMWithoutSaving(ctx context.Context, bom *cyclonedx.BOM) (dtos.ScanResponse, error) ScanSarifWithoutSaving(ctx context.Context, sarifScan sarif.SarifSchema210Json, scannerID string) (dtos.FirstPartyScanResponse, error) + FetchOpenVexFromGitHub(ctx context.Context, targetURL string, targetBranch string) (vexReports []*normalize.VexReportOpenVEX, err error) } type ConfigRepository interface { @@ -494,6 +500,7 @@ type VEXRuleService interface { FindByID(ctx context.Context, tx DB, id string) (models.VEXRule, error) FindByAssetVersionAndCVE(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, cveID string) ([]models.VEXRule, error) FindByAssetVersionAndVulnID(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, vulnID uuid.UUID) ([]models.VEXRule, error) + UpdateSystemVEXRulesFromStaticSources(ctx context.Context, reports []*normalize.VexReportOpenVEX) error } type CrowdSourcedVexingService interface { diff --git a/tests/vex_test.go b/tests/vex_test.go index 27e1ba17d..64edafed8 100644 --- a/tests/vex_test.go +++ b/tests/vex_test.go @@ -35,10 +35,12 @@ func TestVEXRuleServiceUpdate(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.Update(context.Background(), nil, rule) assert.NoError(t, err) @@ -59,12 +61,14 @@ func TestVEXRuleServiceDelete(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("Delete", mock.Anything, mock.Anything, mock.MatchedBy(func(r models.VEXRule) bool { return r.ID == "test-rule-1" })).Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.Delete(context.Background(), nil, rule) assert.NoError(t, err) @@ -79,10 +83,12 @@ func TestVEXRuleServiceDeleteByAssetVersion(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("DeleteByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.DeleteByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -111,10 +117,12 @@ func TestVEXRuleServiceFindByAssetVersion(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("FindByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(rules, nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) found, err := service.FindByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -138,10 +146,12 @@ func TestVEXRuleServiceFindByID(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("FindByID", mock.Anything, mock.Anything, "test-rule-1").Return(rule, nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) found, err := service.FindByID(context.Background(), nil, "test-rule-1") assert.NoError(t, err) @@ -191,6 +201,8 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -200,7 +212,7 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) counts, err := service.CountMatchingVulnsForRules(context.Background(), nil, rules) assert.NoError(t, err) @@ -242,6 +254,8 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -251,7 +265,7 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) count, err := service.CountMatchingVulns(context.Background(), nil, rule) assert.NoError(t, err) @@ -275,10 +289,12 @@ func TestVEXRuleServiceCreate(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) vexRuleRepo.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) err := service.Create(context.Background(), nil, rule) assert.NoError(t, err) @@ -318,6 +334,8 @@ func TestApplyRulesToExistingIdempotent(t *testing.T) { vexRuleRepo := mocks.NewVEXRuleRepository(t) depVulnRepo := mocks.NewDependencyVulnRepository(t) vulnEventRepo := mocks.NewVulnEventRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + cveRepo := mocks.NewCveRepository(t) // Track how many events are saved across all calls var totalEventsSaved int @@ -329,7 +347,7 @@ func TestApplyRulesToExistingIdempotent(t *testing.T) { }). Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, depVulnRepo, vulnEventRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) // First call — should create 1 event vulns := []models.DependencyVuln{vuln} diff --git a/transformer/vex_rule_transformer.go b/transformer/vex_rule_transformer.go index 4e4f39f39..144974235 100644 --- a/transformer/vex_rule_transformer.go +++ b/transformer/vex_rule_transformer.go @@ -53,3 +53,22 @@ func VEXRuleToRecommendationDTO(rule models.VEXRule) dtos.VexRuleRecommendation EventType: rule.EventType, } } + +func VEXRuleToSystemVEXRule(rule models.VEXRule) models.SystemVEXRule { + return models.SystemVEXRule{ + ID: rule.ID, + + // Composite key components + CVEID: rule.CVEID, + VexSource: rule.VexSource, + + // Rule data + Justification: rule.Justification, + MechanicalJustification: rule.MechanicalJustification, + EventType: rule.EventType, + PathPattern: dtos.PathPattern(rule.PathPattern), + CreatedByID: rule.CreatedByID, + CreatedAt: rule.CreatedAt, + UpdatedAt: rule.UpdatedAt, + } +} From 98a706db6263528b980370bdf26aeb07395c18ed Mon Sep 17 00:00:00 2001 From: Dennis Tuan Anh Quach <83472928+Dboy0ZDev@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:47:49 +0200 Subject: [PATCH 08/10] Implemented System VexRule as primary recommendation mechanism --- database/models/vex_rule_model.go | 4 --- .../repositories/system_vexrule_repository.go | 6 ++++ services/crowdsourced_vexing_service.go | 30 ++++++++++++++++++- shared/common_interfaces.go | 1 + transformer/vex_rule_transformer.go | 25 ++++++++++++++++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/database/models/vex_rule_model.go b/database/models/vex_rule_model.go index c1ec37b06..ad1ec5091 100644 --- a/database/models/vex_rule_model.go +++ b/database/models/vex_rule_model.go @@ -62,10 +62,6 @@ type VEXRule struct { // When false, the rule exists but does not create events or modify vulnerability state. // Rules are disabled when uploaded in ParanoidMode, requiring manual review/enabling. Enabled bool `json:"enabled" gorm:"default:true;not null;"` - - // IsSystemVEXRule is used to indicate if the VEXRule was created/imported into the system - // with not relation to any other Asset - IsSystemVEXRule bool `json:"isSystemVEXRule" gorm:"default:false;not null;"` } func (VEXRule) TableName() string { diff --git a/database/repositories/system_vexrule_repository.go b/database/repositories/system_vexrule_repository.go index 5380f92b9..2540dc8e3 100644 --- a/database/repositories/system_vexrule_repository.go +++ b/database/repositories/system_vexrule_repository.go @@ -25,6 +25,12 @@ func (r *systemVEXRuleRepository) GetDB(ctx context.Context, tx *gorm.DB) *gorm. return r.db.WithContext(ctx) } +func (r *systemVEXRuleRepository) FindByCVE(ctx context.Context, tx *gorm.DB, cveID string) ([]models.SystemVEXRule, error) { + var rules []models.SystemVEXRule + err := r.GetDB(ctx, tx).Preload("CVE").Where("LOWER(cve_id) = LOWER(?)", cveID).Find(&rules).Error + return rules, err +} + func (r *systemVEXRuleRepository) UpsertBatch(ctx context.Context, tx *gorm.DB, rules []models.SystemVEXRule) error { if len(rules) == 0 { return nil diff --git a/services/crowdsourced_vexing_service.go b/services/crowdsourced_vexing_service.go index dc701d271..f04f3e27e 100644 --- a/services/crowdsourced_vexing_service.go +++ b/services/crowdsourced_vexing_service.go @@ -2,16 +2,20 @@ package services import ( "fmt" + "log/slog" "github.com/google/uuid" "github.com/l3montree-dev/devguard/crowdsourcevexing" "github.com/l3montree-dev/devguard/database/models" + "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/shared" + "github.com/l3montree-dev/devguard/transformer" "github.com/l3montree-dev/devguard/utils" ) type CrowdsourcedVexingService struct { vexRuleRepository shared.VEXRuleRepository + systemVexRuleRepository shared.SystemVEXRuleRepository organisationRepository shared.OrganizationRepository projectRepository shared.ProjectRepository assetVersionRepository shared.AssetVersionRepository @@ -58,9 +62,10 @@ func mapAsset(asset models.Asset) crowdsourcevexing.Asset { } } -func NewCrowdsourcedVexingService(vexRuleRepository shared.VEXRuleRepository, organisationRepository shared.OrganizationRepository, projectRepository shared.ProjectRepository, assetVersionRepository shared.AssetVersionRepository, dependencyVulnRepository shared.DependencyVulnRepository, trustedEntityRepository shared.TrustedEntityRepository, rbacProvider shared.RBACProvider) *CrowdsourcedVexingService { +func NewCrowdsourcedVexingService(vexRuleRepository shared.VEXRuleRepository, systemVexRuleRepository shared.SystemVEXRuleRepository, organisationRepository shared.OrganizationRepository, projectRepository shared.ProjectRepository, assetVersionRepository shared.AssetVersionRepository, dependencyVulnRepository shared.DependencyVulnRepository, trustedEntityRepository shared.TrustedEntityRepository, rbacProvider shared.RBACProvider) *CrowdsourcedVexingService { return &CrowdsourcedVexingService{ vexRuleRepository: vexRuleRepository, + systemVexRuleRepository: systemVexRuleRepository, organisationRepository: organisationRepository, projectRepository: projectRepository, assetVersionRepository: assetVersionRepository, @@ -82,6 +87,12 @@ func (s *CrowdsourcedVexingService) Recommend(ctx shared.Context, tx shared.DB, return models.VEXRule{}, fmt.Errorf("vuln does not belong to this asset") } + systemVexRule, err := s.RecommendSystemVEXRule(ctx, tx, vuln.CVEID, vuln.VulnerabilityPath) + if err == nil { + return transformer.SystemVEXRuleToVEXRule(systemVexRule), nil + } + slog.Info("no suitable system VEXRule for this vuln. continuing with crowdsourced vexing", "err", err) + vexRules, err := s.vexRuleRepository.FindByCVE(requestCtx, tx, vuln.CVEID) if err != nil { return models.VEXRule{}, err @@ -153,3 +164,20 @@ func (s *CrowdsourcedVexingService) Recommend(ctx shared.Context, tx shared.DB, } return rule, nil } + +func (s *CrowdsourcedVexingService) RecommendSystemVEXRule(ctx shared.Context, tx shared.DB, cveID string, dependencyPath []string) (models.SystemVEXRule, error) { + rules, err := s.systemVexRuleRepository.FindByCVE(ctx.Request().Context(), tx, cveID) + if err != nil { + return models.SystemVEXRule{}, err + } + validRules := utils.Filter(rules, func(rule models.SystemVEXRule) bool { + return dtos.PathPattern(rule.PathPattern).MatchesSuffix(dependencyPath) + }) + if len(validRules) == 0 { + return models.SystemVEXRule{}, fmt.Errorf("no system VEX rules found for CVE: %s", cveID) + } + if len(validRules) > 1 { + return models.SystemVEXRule{}, fmt.Errorf("multiple system VEX rules found for CVE: %s, cannot determine which one to recommend", cveID) + } + return validRules[0], nil +} diff --git a/shared/common_interfaces.go b/shared/common_interfaces.go index 6e6e1388a..85e541b6b 100644 --- a/shared/common_interfaces.go +++ b/shared/common_interfaces.go @@ -341,6 +341,7 @@ type VEXRuleRepository interface { type SystemVEXRuleRepository interface { GetDB(ctx context.Context, db DB) DB + FindByCVE(ctx context.Context, tx DB, cveID string) ([]models.SystemVEXRule, error) UpsertBatch(ctx context.Context, tx DB, rules []models.SystemVEXRule) error } diff --git a/transformer/vex_rule_transformer.go b/transformer/vex_rule_transformer.go index 144974235..c618cce30 100644 --- a/transformer/vex_rule_transformer.go +++ b/transformer/vex_rule_transformer.go @@ -72,3 +72,28 @@ func VEXRuleToSystemVEXRule(rule models.VEXRule) models.SystemVEXRule { UpdatedAt: rule.UpdatedAt, } } + +func SystemVEXRuleToVEXRule(systemRule models.SystemVEXRule) models.VEXRule { + return models.VEXRule{ + ID: systemRule.ID, + + // Composite key components + CVEID: systemRule.CVEID, + VexSource: systemRule.VexSource, + + CreatedAt: systemRule.CreatedAt, + UpdatedAt: systemRule.UpdatedAt, + + CVE: systemRule.CVE, + + // Rule data + Justification: systemRule.Justification, + MechanicalJustification: systemRule.MechanicalJustification, + EventType: systemRule.EventType, + + PathPattern: dtos.PathPattern(systemRule.PathPattern), + CreatedByID: systemRule.CreatedByID, + + Enabled: false, + } +} From a80aca40893e0c75ada68e503e20c268d0b3d30b Mon Sep 17 00:00:00 2001 From: Dennis Tuan Anh Quach <83472928+Dboy0ZDev@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:44:14 +0200 Subject: [PATCH 09/10] Implemented Auto apply feature for systemvexrules --- daemons/daemon.go | 6 + daemons/providers.go | 3 + daemons/system_vexrule_daemon.go | 72 +++ .../repositories/asset_version_repository.go | 16 + .../cve_relationship_repository.go | 41 ++ .../dependency_vuln_repository.go | 30 + .../repositories/system_vexrule_repository.go | 18 + database/repositories/vex_rule_repository.go | 11 + mocks/mock_AssetVersionRepository.go | 68 +++ mocks/mock_CVERelationshipRepository.go | 74 +++ mocks/mock_CVERelationshipService.go | 176 ++++++ mocks/mock_DependencyVulnRepository.go | 98 ++++ mocks/mock_SystemVEXRuleRepository.go | 216 +++++++ mocks/mock_VEXRuleRepository.go | 86 +++ mocks/mock_VEXRuleService.go | 71 +++ services/crowdsourced_vexing_service.go | 36 +- services/cve_relationship_service.go | 55 ++ services/providers.go | 4 + services/vex_rule_service.go | 94 ++- services/vex_rule_service_test.go | 357 +++++++++++- shared/common_interfaces.go | 19 + tests/daemon_pipeline_test.go | 548 ++++++++++++++++++ tests/fx_test_app.go | 1 + tests/fx_test_helpers.go | 2 + tests/vex_test.go | 36 +- transformer/vex_rule_transformer.go | 8 +- 26 files changed, 2084 insertions(+), 62 deletions(-) create mode 100644 daemons/system_vexrule_daemon.go create mode 100644 mocks/mock_CVERelationshipService.go create mode 100644 services/cve_relationship_service.go diff --git a/daemons/daemon.go b/daemons/daemon.go index e848c8a0c..cdceade15 100644 --- a/daemons/daemon.go +++ b/daemons/daemon.go @@ -107,4 +107,10 @@ func (runner *DaemonRunner) runDaemons(ctx context.Context) { }); err != nil { slog.Error("could not update system vex rules from openvex sources", "err", err) } + + if err := runner.maybeRunAndMark(ctx, "systemvexrules.applySystemVEXRules", func() error { + return runner.ApplySystemVEXRules(ctx) + }); err != nil { + slog.Error("could not apply system vex rules to assets", "err", err) + } } diff --git a/daemons/providers.go b/daemons/providers.go index 4a3ae012b..708dcb514 100644 --- a/daemons/providers.go +++ b/daemons/providers.go @@ -72,6 +72,7 @@ type DaemonRunner struct { maliciousPackageChecker shared.MaliciousPackageChecker vulnDBImportService shared.VulnDBService vexRuleService shared.VEXRuleService + systemVEXRuleRepository shared.SystemVEXRuleRepository debugOptions DebugOptions fixedVersionResolver shared.FixedVersionResolver @@ -125,6 +126,7 @@ func NewDaemonRunner( vulnDBImportService shared.VulnDBService, vexRuleService shared.VEXRuleService, fixedVersionResolver shared.FixedVersionResolver, + systemVEXRuleRepository shared.SystemVEXRuleRepository, ) *DaemonRunner { return &DaemonRunner{ db: db, @@ -156,6 +158,7 @@ func NewDaemonRunner( vulnDBImportService: vulnDBImportService, vexRuleService: vexRuleService, fixedVersionResolver: fixedVersionResolver, + systemVEXRuleRepository: systemVEXRuleRepository, } } diff --git a/daemons/system_vexrule_daemon.go b/daemons/system_vexrule_daemon.go new file mode 100644 index 000000000..03d16cdce --- /dev/null +++ b/daemons/system_vexrule_daemon.go @@ -0,0 +1,72 @@ +package daemons + +import ( + "context" + "log/slog" + + "github.com/l3montree-dev/devguard/database/models" +) + +func (runner *DaemonRunner) ApplySystemVEXRules(ctx context.Context) error { + tx := runner.systemVEXRuleRepository.GetDB(ctx, nil) + var err error + var assetVersions []models.AssetVersion + var systemVEXRules []models.SystemVEXRule + // Gather all AssetVersions + // Check paranoid mode or settings if auto apply is enabled (can be found in asset) + // Add application setting in asset model + // Change application setting when paranoid mode is changed + assetVersions, err = runner.assetVersionRepository.FindSystemVEXRuleApplicableAssetVersions(ctx, tx) + if err != nil { + slog.Error("failed to fetch assetVersions from database", "error", err) + return err + } + + if len(assetVersions) < 1 { + slog.Info("No assetversions in database yet, skipping SystemVEXRule application") + return nil + } + // Gather all system vexrules + systemVEXRules, err = runner.systemVEXRuleRepository.All(ctx, tx) + if err != nil { + slog.Error("failed to fetch systemVEXRules from database", "error", err) + return err + } + + if len(systemVEXRules) < 1 { + slog.Info("No SystemVEXRules in database yet, skipping SystemVEXRule application") + return nil + } + // Create VexRules from all AssetVersions and System vexrules + var applicableRules []models.VEXRule + for _, assetVersion := range assetVersions { + for _, systemVEXRule := range systemVEXRules { + // This VEXRule is only created temporarily for the execution of the daemon, because + // storing the VEXRule would interfere with the crowdsourced vexing + rule := models.VEXRule{ + AssetID: assetVersion.Asset.ID, + AssetVersionName: assetVersion.Name, + CVEID: systemVEXRule.CVEID, + VexSource: systemVEXRule.VexSource, + Asset: assetVersion.Asset, + CVE: systemVEXRule.CVE, + AssetVersion: assetVersion, + Justification: systemVEXRule.Justification, + EventType: systemVEXRule.EventType, + PathPattern: systemVEXRule.PathPattern, + MechanicalJustification: systemVEXRule.MechanicalJustification, + CreatedByID: "system", + Enabled: true, + } + rule.SetPathPattern(systemVEXRule.PathPattern) + applicableRules = append(applicableRules, rule) + } + } + // ApplyRulesToExistingVulns() + _, err = runner.vexRuleService.ApplyRulesToExistingVulns(ctx, tx, applicableRules) + if err != nil { + slog.Error("failed to apply system VEX rules", "error", err) + return err + } + return nil +} diff --git a/database/repositories/asset_version_repository.go b/database/repositories/asset_version_repository.go index 979805865..a859d339d 100644 --- a/database/repositories/asset_version_repository.go +++ b/database/repositories/asset_version_repository.go @@ -380,3 +380,19 @@ func (repository *assetVersionRepository) GetAmountOfAssetVersionsInOrg(ctx cont `, orgID).Find(&totalAmount).Error return totalAmount, err } + +func (repository *assetVersionRepository) FindSystemVEXRuleApplicableAssetVersions(ctx context.Context, tx *gorm.DB) ([]models.AssetVersion, error) { + var assetVersions []models.AssetVersion + + err := repository.GetDB(ctx, tx). + Model(&models.AssetVersion{}). + Joins("Asset"). + Where( + `"Asset"."paranoid_mode" = ?`, + false, + ). + Preload("Asset"). + Find(&assetVersions).Error + + return assetVersions, err +} diff --git a/database/repositories/cve_relationship_repository.go b/database/repositories/cve_relationship_repository.go index d211c8f10..d31de7ef7 100644 --- a/database/repositories/cve_relationship_repository.go +++ b/database/repositories/cve_relationship_repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "github.com/l3montree-dev/devguard/database/models" "github.com/l3montree-dev/devguard/utils" "gorm.io/gorm" @@ -27,3 +28,43 @@ func (repository *cveRelationshipRepository) GetRelationshipsByTargetCVEBatch(ct } return relations, nil } + +func (repository *cveRelationshipRepository) FindCrossRelationshipsBatch( + ctx context.Context, + tx *gorm.DB, + associatedCVEIDs []string, +) ([]models.CVERelationship, error) { + + const chunkSize = 10000 + + lowerIDs := utils.ToLowerSlice(associatedCVEIDs) + + var result []models.CVERelationship + + for start := 0; start < len(lowerIDs); start += chunkSize { + end := start + chunkSize + if end > len(lowerIDs) { + end = len(lowerIDs) + } + + chunk := lowerIDs[start:end] + + var relationships []models.CVERelationship + + err := repository.GetDB(ctx, tx). + Where( + "LOWER(target_cve) IN ? OR LOWER(source_cve) IN ?", + chunk, + chunk, + ). + Find(&relationships).Error + + if err != nil { + return nil, err + } + + result = append(result, relationships...) + } + + return result, nil +} diff --git a/database/repositories/dependency_vuln_repository.go b/database/repositories/dependency_vuln_repository.go index 89baaed73..d73b2b48a 100644 --- a/database/repositories/dependency_vuln_repository.go +++ b/database/repositories/dependency_vuln_repository.go @@ -406,6 +406,36 @@ func (repository *dependencyVulnRepository) GetAllOpenVulnsByAssetVersionNameAnd } +func (repository *dependencyVulnRepository) GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch( + ctx context.Context, + tx *gorm.DB, + assetTuples []struct { + AssetID string + AssetVersionName string + }, +) ([]models.DependencyVuln, error) { + var vulns = []models.DependencyVuln{} + + var args []interface{} + var placeholders []string + + for _, key := range assetTuples { + placeholders = append(placeholders, "(?, ?)") + args = append(args, key.AssetID, key.AssetVersionName) + } + + query := fmt.Sprintf( + "(asset_id, asset_version_name) IN (%s)", + strings.Join(placeholders, ","), + ) + + if err := repository.Repository.GetDB(ctx, tx).Preload("CVE").Where(query, args...).Find(&vulns).Error; err != nil { + return nil, err + } + return vulns, nil + +} + // Override the base GetAllVulnsByAssetID method to preload artifacts func (repository *dependencyVulnRepository) GetAllVulnsByAssetID(ctx context.Context, tx *gorm.DB, assetID uuid.UUID) ([]models.DependencyVuln, error) { var vulns = []models.DependencyVuln{} diff --git a/database/repositories/system_vexrule_repository.go b/database/repositories/system_vexrule_repository.go index 2540dc8e3..d8715621d 100644 --- a/database/repositories/system_vexrule_repository.go +++ b/database/repositories/system_vexrule_repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "strings" "github.com/l3montree-dev/devguard/database/models" "gorm.io/gorm" @@ -18,6 +19,13 @@ func NewSystemVEXRuleRepository(db *gorm.DB) *systemVEXRuleRepository { } } +func (r *systemVEXRuleRepository) All(ctx context.Context, tx *gorm.DB) ([]models.SystemVEXRule, error) { + var result []models.SystemVEXRule + + err := r.GetDB(ctx, tx).Preload("CVE.Relationships").Find(&result).Error + return result, err +} + func (r *systemVEXRuleRepository) GetDB(ctx context.Context, tx *gorm.DB) *gorm.DB { if tx != nil { return tx @@ -31,6 +39,16 @@ func (r *systemVEXRuleRepository) FindByCVE(ctx context.Context, tx *gorm.DB, cv return rules, err } +func (r *systemVEXRuleRepository) FindByCVEBatch(ctx context.Context, tx *gorm.DB, cveIDs []string) ([]models.SystemVEXRule, error) { + var rules []models.SystemVEXRule + var lowercaseCVEs []string + for _, cve := range cveIDs { + lowercaseCVEs = append(lowercaseCVEs, strings.ToLower(cve)) + } + err := r.GetDB(ctx, tx).Preload("CVE").Where("LOWER(cve_id) IN ?", lowercaseCVEs).Find(&rules).Error + return rules, err +} + func (r *systemVEXRuleRepository) UpsertBatch(ctx context.Context, tx *gorm.DB, rules []models.SystemVEXRule) error { if len(rules) == 0 { return nil diff --git a/database/repositories/vex_rule_repository.go b/database/repositories/vex_rule_repository.go index b4143e7a9..49a773b16 100644 --- a/database/repositories/vex_rule_repository.go +++ b/database/repositories/vex_rule_repository.go @@ -17,6 +17,7 @@ package repositories import ( "context" + "strings" "github.com/google/uuid" "github.com/l3montree-dev/devguard/database/models" @@ -73,6 +74,16 @@ func (r *vexRuleRepository) FindByAssetVersionAndCVE(ctx context.Context, tx *go return rules, err } +func (r *vexRuleRepository) FindByAssetVersionAndCVEAliases(ctx context.Context, tx *gorm.DB, assetID uuid.UUID, assetVersionName string, cveIDs []string) ([]models.VEXRule, error) { + var rules []models.VEXRule + var lowercaseCVEs []string + for _, cve := range cveIDs { + lowercaseCVEs = append(lowercaseCVEs, strings.ToLower(cve)) + } + err := r.GetDB(ctx, tx).Where("asset_id = ? AND asset_version_name = ? AND LOWER(cve_id) IN ?", assetID, assetVersionName, lowercaseCVEs).Order("created_at DESC").Find(&rules).Error + return rules, err +} + func (r *vexRuleRepository) FindByAssetVersionPaged(ctx context.Context, tx *gorm.DB, assetID uuid.UUID, assetVersionName string, pageInfo shared.PageInfo, search string, filterQuery []shared.FilterQuery, sortQuery []shared.SortQuery) (shared.Paged[models.VEXRule], error) { var rules []models.VEXRule var total int64 diff --git a/mocks/mock_AssetVersionRepository.go b/mocks/mock_AssetVersionRepository.go index 953be143d..6fdd3e6d4 100644 --- a/mocks/mock_AssetVersionRepository.go +++ b/mocks/mock_AssetVersionRepository.go @@ -464,6 +464,74 @@ func (_c *AssetVersionRepository_FindOrCreate_Call) RunAndReturn(run func(ctx co return _c } +// FindSystemVEXRuleApplicableAssetVersions provides a mock function for the type AssetVersionRepository +func (_mock *AssetVersionRepository) FindSystemVEXRuleApplicableAssetVersions(ctx context.Context, tx shared.DB) ([]models.AssetVersion, error) { + ret := _mock.Called(ctx, tx) + + if len(ret) == 0 { + panic("no return value specified for FindSystemVEXRuleApplicableAssetVersions") + } + + var r0 []models.AssetVersion + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB) ([]models.AssetVersion, error)); ok { + return returnFunc(ctx, tx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB) []models.AssetVersion); ok { + r0 = returnFunc(ctx, tx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.AssetVersion) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB) error); ok { + r1 = returnFunc(ctx, tx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindSystemVEXRuleApplicableAssetVersions' +type AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call struct { + *mock.Call +} + +// FindSystemVEXRuleApplicableAssetVersions is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +func (_e *AssetVersionRepository_Expecter) FindSystemVEXRuleApplicableAssetVersions(ctx interface{}, tx interface{}) *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call { + return &AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call{Call: _e.mock.On("FindSystemVEXRuleApplicableAssetVersions", ctx, tx)} +} + +func (_c *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call) Run(run func(ctx context.Context, tx shared.DB)) *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call) Return(assetVersions []models.AssetVersion, err error) *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call { + _c.Call.Return(assetVersions, err) + return _c +} + +func (_c *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB) ([]models.AssetVersion, error)) *AssetVersionRepository_FindSystemVEXRuleApplicableAssetVersions_Call { + _c.Call.Return(run) + return _c +} + // GetAllTagsAndDefaultBranchForAsset provides a mock function for the type AssetVersionRepository func (_mock *AssetVersionRepository) GetAllTagsAndDefaultBranchForAsset(ctx context.Context, tx shared.DB, assetID uuid.UUID) ([]models.AssetVersion, error) { ret := _mock.Called(ctx, tx, assetID) diff --git a/mocks/mock_CVERelationshipRepository.go b/mocks/mock_CVERelationshipRepository.go index 33fe1720c..68917f629 100644 --- a/mocks/mock_CVERelationshipRepository.go +++ b/mocks/mock_CVERelationshipRepository.go @@ -527,6 +527,80 @@ func (_c *CVERelationshipRepository_DeleteBatch_Call) RunAndReturn(run func(ctx return _c } +// FindCrossRelationshipsBatch provides a mock function for the type CVERelationshipRepository +func (_mock *CVERelationshipRepository) FindCrossRelationshipsBatch(ctx context.Context, tx shared.DB, assiciatedCVEIDs []string) ([]models.CVERelationship, error) { + ret := _mock.Called(ctx, tx, assiciatedCVEIDs) + + if len(ret) == 0 { + panic("no return value specified for FindCrossRelationshipsBatch") + } + + var r0 []models.CVERelationship + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) ([]models.CVERelationship, error)); ok { + return returnFunc(ctx, tx, assiciatedCVEIDs) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) []models.CVERelationship); ok { + r0 = returnFunc(ctx, tx, assiciatedCVEIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CVERelationship) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, []string) error); ok { + r1 = returnFunc(ctx, tx, assiciatedCVEIDs) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// CVERelationshipRepository_FindCrossRelationshipsBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindCrossRelationshipsBatch' +type CVERelationshipRepository_FindCrossRelationshipsBatch_Call struct { + *mock.Call +} + +// FindCrossRelationshipsBatch is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - assiciatedCVEIDs []string +func (_e *CVERelationshipRepository_Expecter) FindCrossRelationshipsBatch(ctx interface{}, tx interface{}, assiciatedCVEIDs interface{}) *CVERelationshipRepository_FindCrossRelationshipsBatch_Call { + return &CVERelationshipRepository_FindCrossRelationshipsBatch_Call{Call: _e.mock.On("FindCrossRelationshipsBatch", ctx, tx, assiciatedCVEIDs)} +} + +func (_c *CVERelationshipRepository_FindCrossRelationshipsBatch_Call) Run(run func(ctx context.Context, tx shared.DB, assiciatedCVEIDs []string)) *CVERelationshipRepository_FindCrossRelationshipsBatch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []string + if args[2] != nil { + arg2 = args[2].([]string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *CVERelationshipRepository_FindCrossRelationshipsBatch_Call) Return(cVERelationships []models.CVERelationship, err error) *CVERelationshipRepository_FindCrossRelationshipsBatch_Call { + _c.Call.Return(cVERelationships, err) + return _c +} + +func (_c *CVERelationshipRepository_FindCrossRelationshipsBatch_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, assiciatedCVEIDs []string) ([]models.CVERelationship, error)) *CVERelationshipRepository_FindCrossRelationshipsBatch_Call { + _c.Call.Return(run) + return _c +} + // GetDB provides a mock function for the type CVERelationshipRepository func (_mock *CVERelationshipRepository) GetDB(ctx context.Context, tx shared.DB) shared.DB { ret := _mock.Called(ctx, tx) diff --git a/mocks/mock_CVERelationshipService.go b/mocks/mock_CVERelationshipService.go new file mode 100644 index 000000000..888769d00 --- /dev/null +++ b/mocks/mock_CVERelationshipService.go @@ -0,0 +1,176 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/l3montree-dev/devguard/shared" + mock "github.com/stretchr/testify/mock" +) + +// NewCVERelationshipService creates a new instance of CVERelationshipService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewCVERelationshipService(t interface { + mock.TestingT + Cleanup(func()) +}) *CVERelationshipService { + mock := &CVERelationshipService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// CVERelationshipService is an autogenerated mock type for the CVERelationshipService type +type CVERelationshipService struct { + mock.Mock +} + +type CVERelationshipService_Expecter struct { + mock *mock.Mock +} + +func (_m *CVERelationshipService) EXPECT() *CVERelationshipService_Expecter { + return &CVERelationshipService_Expecter{mock: &_m.Mock} +} + +// CreateAliasRelationshipMapBatch provides a mock function for the type CVERelationshipService +func (_mock *CVERelationshipService) CreateAliasRelationshipMapBatch(ctx context.Context, tx shared.DB, cveIDs []string) (map[string]map[string]struct{}, error) { + ret := _mock.Called(ctx, tx, cveIDs) + + if len(ret) == 0 { + panic("no return value specified for CreateAliasRelationshipMapBatch") + } + + var r0 map[string]map[string]struct{} + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) (map[string]map[string]struct{}, error)); ok { + return returnFunc(ctx, tx, cveIDs) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) map[string]map[string]struct{}); ok { + r0 = returnFunc(ctx, tx, cveIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]map[string]struct{}) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, []string) error); ok { + r1 = returnFunc(ctx, tx, cveIDs) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// CVERelationshipService_CreateAliasRelationshipMapBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateAliasRelationshipMapBatch' +type CVERelationshipService_CreateAliasRelationshipMapBatch_Call struct { + *mock.Call +} + +// CreateAliasRelationshipMapBatch is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - cveIDs []string +func (_e *CVERelationshipService_Expecter) CreateAliasRelationshipMapBatch(ctx interface{}, tx interface{}, cveIDs interface{}) *CVERelationshipService_CreateAliasRelationshipMapBatch_Call { + return &CVERelationshipService_CreateAliasRelationshipMapBatch_Call{Call: _e.mock.On("CreateAliasRelationshipMapBatch", ctx, tx, cveIDs)} +} + +func (_c *CVERelationshipService_CreateAliasRelationshipMapBatch_Call) Run(run func(ctx context.Context, tx shared.DB, cveIDs []string)) *CVERelationshipService_CreateAliasRelationshipMapBatch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []string + if args[2] != nil { + arg2 = args[2].([]string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *CVERelationshipService_CreateAliasRelationshipMapBatch_Call) Return(stringToStringToVal map[string]map[string]struct{}, err error) *CVERelationshipService_CreateAliasRelationshipMapBatch_Call { + _c.Call.Return(stringToStringToVal, err) + return _c +} + +func (_c *CVERelationshipService_CreateAliasRelationshipMapBatch_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, cveIDs []string) (map[string]map[string]struct{}, error)) *CVERelationshipService_CreateAliasRelationshipMapBatch_Call { + _c.Call.Return(run) + return _c +} + +// IsAlias provides a mock function for the type CVERelationshipService +func (_mock *CVERelationshipService) IsAlias(cveSource string, cveTarget string, cveMap map[string]map[string]struct{}) bool { + ret := _mock.Called(cveSource, cveTarget, cveMap) + + if len(ret) == 0 { + panic("no return value specified for IsAlias") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func(string, string, map[string]map[string]struct{}) bool); ok { + r0 = returnFunc(cveSource, cveTarget, cveMap) + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// CVERelationshipService_IsAlias_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAlias' +type CVERelationshipService_IsAlias_Call struct { + *mock.Call +} + +// IsAlias is a helper method to define mock.On call +// - cveSource string +// - cveTarget string +// - cveMap map[string]map[string]struct{} +func (_e *CVERelationshipService_Expecter) IsAlias(cveSource interface{}, cveTarget interface{}, cveMap interface{}) *CVERelationshipService_IsAlias_Call { + return &CVERelationshipService_IsAlias_Call{Call: _e.mock.On("IsAlias", cveSource, cveTarget, cveMap)} +} + +func (_c *CVERelationshipService_IsAlias_Call) Run(run func(cveSource string, cveTarget string, cveMap map[string]map[string]struct{})) *CVERelationshipService_IsAlias_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 map[string]map[string]struct{} + if args[2] != nil { + arg2 = args[2].(map[string]map[string]struct{}) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *CVERelationshipService_IsAlias_Call) Return(b bool) *CVERelationshipService_IsAlias_Call { + _c.Call.Return(b) + return _c +} + +func (_c *CVERelationshipService_IsAlias_Call) RunAndReturn(run func(cveSource string, cveTarget string, cveMap map[string]map[string]struct{}) bool) *CVERelationshipService_IsAlias_Call { + _c.Call.Return(run) + return _c +} diff --git a/mocks/mock_DependencyVulnRepository.go b/mocks/mock_DependencyVulnRepository.go index eaff948aa..3fe81a36d 100644 --- a/mocks/mock_DependencyVulnRepository.go +++ b/mocks/mock_DependencyVulnRepository.go @@ -857,6 +857,104 @@ func (_c *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetID_C return _c } +// GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch provides a mock function for the type DependencyVulnRepository +func (_mock *DependencyVulnRepository) GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch(ctx context.Context, tx shared.DB, assetTuples []struct { + AssetID string + AssetVersionName string +}) ([]models.DependencyVuln, error) { + ret := _mock.Called(ctx, tx, assetTuples) + + if len(ret) == 0 { + panic("no return value specified for GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch") + } + + var r0 []models.DependencyVuln + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []struct { + AssetID string + AssetVersionName string + }) ([]models.DependencyVuln, error)); ok { + return returnFunc(ctx, tx, assetTuples) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []struct { + AssetID string + AssetVersionName string + }) []models.DependencyVuln); ok { + r0 = returnFunc(ctx, tx, assetTuples) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.DependencyVuln) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, []struct { + AssetID string + AssetVersionName string + }) error); ok { + r1 = returnFunc(ctx, tx, assetTuples) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch' +type DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call struct { + *mock.Call +} + +// GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - assetTuples []struct{AssetID string; AssetVersionName string} +func (_e *DependencyVulnRepository_Expecter) GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch(ctx interface{}, tx interface{}, assetTuples interface{}) *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call { + return &DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call{Call: _e.mock.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", ctx, tx, assetTuples)} +} + +func (_c *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call) Run(run func(ctx context.Context, tx shared.DB, assetTuples []struct { + AssetID string + AssetVersionName string +})) *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []struct { + AssetID string + AssetVersionName string + } + if args[2] != nil { + arg2 = args[2].([]struct { + AssetID string + AssetVersionName string + }) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call) Return(dependencyVulns []models.DependencyVuln, err error) *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call { + _c.Call.Return(dependencyVulns, err) + return _c +} + +func (_c *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, assetTuples []struct { + AssetID string + AssetVersionName string +}) ([]models.DependencyVuln, error)) *DependencyVulnRepository_GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch_Call { + _c.Call.Return(run) + return _c +} + // GetAllVulnsByArtifact provides a mock function for the type DependencyVulnRepository func (_mock *DependencyVulnRepository) GetAllVulnsByArtifact(ctx context.Context, tx shared.DB, artifact models.Artifact) ([]models.DependencyVuln, error) { ret := _mock.Called(ctx, tx, artifact) diff --git a/mocks/mock_SystemVEXRuleRepository.go b/mocks/mock_SystemVEXRuleRepository.go index 33fa63073..c87de933d 100644 --- a/mocks/mock_SystemVEXRuleRepository.go +++ b/mocks/mock_SystemVEXRuleRepository.go @@ -39,6 +39,222 @@ func (_m *SystemVEXRuleRepository) EXPECT() *SystemVEXRuleRepository_Expecter { return &SystemVEXRuleRepository_Expecter{mock: &_m.Mock} } +// All provides a mock function for the type SystemVEXRuleRepository +func (_mock *SystemVEXRuleRepository) All(ctx context.Context, tx shared.DB) ([]models.SystemVEXRule, error) { + ret := _mock.Called(ctx, tx) + + if len(ret) == 0 { + panic("no return value specified for All") + } + + var r0 []models.SystemVEXRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB) ([]models.SystemVEXRule, error)); ok { + return returnFunc(ctx, tx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB) []models.SystemVEXRule); ok { + r0 = returnFunc(ctx, tx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.SystemVEXRule) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB) error); ok { + r1 = returnFunc(ctx, tx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SystemVEXRuleRepository_All_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'All' +type SystemVEXRuleRepository_All_Call struct { + *mock.Call +} + +// All is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +func (_e *SystemVEXRuleRepository_Expecter) All(ctx interface{}, tx interface{}) *SystemVEXRuleRepository_All_Call { + return &SystemVEXRuleRepository_All_Call{Call: _e.mock.On("All", ctx, tx)} +} + +func (_c *SystemVEXRuleRepository_All_Call) Run(run func(ctx context.Context, tx shared.DB)) *SystemVEXRuleRepository_All_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *SystemVEXRuleRepository_All_Call) Return(systemVEXRules []models.SystemVEXRule, err error) *SystemVEXRuleRepository_All_Call { + _c.Call.Return(systemVEXRules, err) + return _c +} + +func (_c *SystemVEXRuleRepository_All_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB) ([]models.SystemVEXRule, error)) *SystemVEXRuleRepository_All_Call { + _c.Call.Return(run) + return _c +} + +// FindByCVE provides a mock function for the type SystemVEXRuleRepository +func (_mock *SystemVEXRuleRepository) FindByCVE(ctx context.Context, tx shared.DB, cveID string) ([]models.SystemVEXRule, error) { + ret := _mock.Called(ctx, tx, cveID) + + if len(ret) == 0 { + panic("no return value specified for FindByCVE") + } + + var r0 []models.SystemVEXRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, string) ([]models.SystemVEXRule, error)); ok { + return returnFunc(ctx, tx, cveID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, string) []models.SystemVEXRule); ok { + r0 = returnFunc(ctx, tx, cveID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.SystemVEXRule) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, string) error); ok { + r1 = returnFunc(ctx, tx, cveID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SystemVEXRuleRepository_FindByCVE_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByCVE' +type SystemVEXRuleRepository_FindByCVE_Call struct { + *mock.Call +} + +// FindByCVE is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - cveID string +func (_e *SystemVEXRuleRepository_Expecter) FindByCVE(ctx interface{}, tx interface{}, cveID interface{}) *SystemVEXRuleRepository_FindByCVE_Call { + return &SystemVEXRuleRepository_FindByCVE_Call{Call: _e.mock.On("FindByCVE", ctx, tx, cveID)} +} + +func (_c *SystemVEXRuleRepository_FindByCVE_Call) Run(run func(ctx context.Context, tx shared.DB, cveID string)) *SystemVEXRuleRepository_FindByCVE_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *SystemVEXRuleRepository_FindByCVE_Call) Return(systemVEXRules []models.SystemVEXRule, err error) *SystemVEXRuleRepository_FindByCVE_Call { + _c.Call.Return(systemVEXRules, err) + return _c +} + +func (_c *SystemVEXRuleRepository_FindByCVE_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, cveID string) ([]models.SystemVEXRule, error)) *SystemVEXRuleRepository_FindByCVE_Call { + _c.Call.Return(run) + return _c +} + +// FindByCVEBatch provides a mock function for the type SystemVEXRuleRepository +func (_mock *SystemVEXRuleRepository) FindByCVEBatch(ctx context.Context, tx shared.DB, cveIDs []string) ([]models.SystemVEXRule, error) { + ret := _mock.Called(ctx, tx, cveIDs) + + if len(ret) == 0 { + panic("no return value specified for FindByCVEBatch") + } + + var r0 []models.SystemVEXRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) ([]models.SystemVEXRule, error)); ok { + return returnFunc(ctx, tx, cveIDs) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []string) []models.SystemVEXRule); ok { + r0 = returnFunc(ctx, tx, cveIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.SystemVEXRule) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, []string) error); ok { + r1 = returnFunc(ctx, tx, cveIDs) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// SystemVEXRuleRepository_FindByCVEBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByCVEBatch' +type SystemVEXRuleRepository_FindByCVEBatch_Call struct { + *mock.Call +} + +// FindByCVEBatch is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - cveIDs []string +func (_e *SystemVEXRuleRepository_Expecter) FindByCVEBatch(ctx interface{}, tx interface{}, cveIDs interface{}) *SystemVEXRuleRepository_FindByCVEBatch_Call { + return &SystemVEXRuleRepository_FindByCVEBatch_Call{Call: _e.mock.On("FindByCVEBatch", ctx, tx, cveIDs)} +} + +func (_c *SystemVEXRuleRepository_FindByCVEBatch_Call) Run(run func(ctx context.Context, tx shared.DB, cveIDs []string)) *SystemVEXRuleRepository_FindByCVEBatch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []string + if args[2] != nil { + arg2 = args[2].([]string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *SystemVEXRuleRepository_FindByCVEBatch_Call) Return(systemVEXRules []models.SystemVEXRule, err error) *SystemVEXRuleRepository_FindByCVEBatch_Call { + _c.Call.Return(systemVEXRules, err) + return _c +} + +func (_c *SystemVEXRuleRepository_FindByCVEBatch_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, cveIDs []string) ([]models.SystemVEXRule, error)) *SystemVEXRuleRepository_FindByCVEBatch_Call { + _c.Call.Return(run) + return _c +} + // GetDB provides a mock function for the type SystemVEXRuleRepository func (_mock *SystemVEXRuleRepository) GetDB(ctx context.Context, db shared.DB) shared.DB { ret := _mock.Called(ctx, db) diff --git a/mocks/mock_VEXRuleRepository.go b/mocks/mock_VEXRuleRepository.go index 8ece0430b..347421623 100644 --- a/mocks/mock_VEXRuleRepository.go +++ b/mocks/mock_VEXRuleRepository.go @@ -665,6 +665,92 @@ func (_c *VEXRuleRepository_FindByAssetVersionAndCVE_Call) RunAndReturn(run func return _c } +// FindByAssetVersionAndCVEAliases provides a mock function for the type VEXRuleRepository +func (_mock *VEXRuleRepository) FindByAssetVersionAndCVEAliases(ctx context.Context, tx shared.DB, assetID uuid.UUID, assetVersionName string, cveIDs []string) ([]models.VEXRule, error) { + ret := _mock.Called(ctx, tx, assetID, assetVersionName, cveIDs) + + if len(ret) == 0 { + panic("no return value specified for FindByAssetVersionAndCVEAliases") + } + + var r0 []models.VEXRule + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, uuid.UUID, string, []string) ([]models.VEXRule, error)); ok { + return returnFunc(ctx, tx, assetID, assetVersionName, cveIDs) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, uuid.UUID, string, []string) []models.VEXRule); ok { + r0 = returnFunc(ctx, tx, assetID, assetVersionName, cveIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.VEXRule) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, shared.DB, uuid.UUID, string, []string) error); ok { + r1 = returnFunc(ctx, tx, assetID, assetVersionName, cveIDs) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByAssetVersionAndCVEAliases' +type VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call struct { + *mock.Call +} + +// FindByAssetVersionAndCVEAliases is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - assetID uuid.UUID +// - assetVersionName string +// - cveIDs []string +func (_e *VEXRuleRepository_Expecter) FindByAssetVersionAndCVEAliases(ctx interface{}, tx interface{}, assetID interface{}, assetVersionName interface{}, cveIDs interface{}) *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call { + return &VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call{Call: _e.mock.On("FindByAssetVersionAndCVEAliases", ctx, tx, assetID, assetVersionName, cveIDs)} +} + +func (_c *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call) Run(run func(ctx context.Context, tx shared.DB, assetID uuid.UUID, assetVersionName string, cveIDs []string)) *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 uuid.UUID + if args[2] != nil { + arg2 = args[2].(uuid.UUID) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + var arg4 []string + if args[4] != nil { + arg4 = args[4].([]string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call) Return(vEXRules []models.VEXRule, err error) *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call { + _c.Call.Return(vEXRules, err) + return _c +} + +func (_c *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, assetID uuid.UUID, assetVersionName string, cveIDs []string) ([]models.VEXRule, error)) *VEXRuleRepository_FindByAssetVersionAndCVEAliases_Call { + _c.Call.Return(run) + return _c +} + // FindByAssetVersionPaged provides a mock function for the type VEXRuleRepository func (_mock *VEXRuleRepository) FindByAssetVersionPaged(ctx context.Context, tx shared.DB, assetID uuid.UUID, assetVersionName string, pageInfo shared.PageInfo, search string, filterQuery []shared.FilterQuery, sortQuery []shared.SortQuery) (shared.Paged[models.VEXRule], error) { ret := _mock.Called(ctx, tx, assetID, assetVersionName, pageInfo, search, filterQuery, sortQuery) diff --git a/mocks/mock_VEXRuleService.go b/mocks/mock_VEXRuleService.go index 0e743782c..5b7ffd6d2 100644 --- a/mocks/mock_VEXRuleService.go +++ b/mocks/mock_VEXRuleService.go @@ -1319,6 +1319,77 @@ func (_c *VEXRuleService_IngestVexes_Call) RunAndReturn(run func(ctx context.Con return _c } +// MatchRulesToVulns provides a mock function for the type VEXRuleService +func (_mock *VEXRuleService) MatchRulesToVulns(ctx context.Context, tx shared.DB, rules []models.VEXRule, vulns []models.DependencyVuln) map[string][]models.DependencyVuln { + ret := _mock.Called(ctx, tx, rules, vulns) + + if len(ret) == 0 { + panic("no return value specified for MatchRulesToVulns") + } + + var r0 map[string][]models.DependencyVuln + if returnFunc, ok := ret.Get(0).(func(context.Context, shared.DB, []models.VEXRule, []models.DependencyVuln) map[string][]models.DependencyVuln); ok { + r0 = returnFunc(ctx, tx, rules, vulns) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string][]models.DependencyVuln) + } + } + return r0 +} + +// VEXRuleService_MatchRulesToVulns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MatchRulesToVulns' +type VEXRuleService_MatchRulesToVulns_Call struct { + *mock.Call +} + +// MatchRulesToVulns is a helper method to define mock.On call +// - ctx context.Context +// - tx shared.DB +// - rules []models.VEXRule +// - vulns []models.DependencyVuln +func (_e *VEXRuleService_Expecter) MatchRulesToVulns(ctx interface{}, tx interface{}, rules interface{}, vulns interface{}) *VEXRuleService_MatchRulesToVulns_Call { + return &VEXRuleService_MatchRulesToVulns_Call{Call: _e.mock.On("MatchRulesToVulns", ctx, tx, rules, vulns)} +} + +func (_c *VEXRuleService_MatchRulesToVulns_Call) Run(run func(ctx context.Context, tx shared.DB, rules []models.VEXRule, vulns []models.DependencyVuln)) *VEXRuleService_MatchRulesToVulns_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 shared.DB + if args[1] != nil { + arg1 = args[1].(shared.DB) + } + var arg2 []models.VEXRule + if args[2] != nil { + arg2 = args[2].([]models.VEXRule) + } + var arg3 []models.DependencyVuln + if args[3] != nil { + arg3 = args[3].([]models.DependencyVuln) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *VEXRuleService_MatchRulesToVulns_Call) Return(stringToDependencyVulns map[string][]models.DependencyVuln) *VEXRuleService_MatchRulesToVulns_Call { + _c.Call.Return(stringToDependencyVulns) + return _c +} + +func (_c *VEXRuleService_MatchRulesToVulns_Call) RunAndReturn(run func(ctx context.Context, tx shared.DB, rules []models.VEXRule, vulns []models.DependencyVuln) map[string][]models.DependencyVuln) *VEXRuleService_MatchRulesToVulns_Call { + _c.Call.Return(run) + return _c +} + // Update provides a mock function for the type VEXRuleService func (_mock *VEXRuleService) Update(ctx context.Context, tx shared.DB, rule *models.VEXRule) error { ret := _mock.Called(ctx, tx, rule) diff --git a/services/crowdsourced_vexing_service.go b/services/crowdsourced_vexing_service.go index f04f3e27e..cfc82d370 100644 --- a/services/crowdsourced_vexing_service.go +++ b/services/crowdsourced_vexing_service.go @@ -22,6 +22,7 @@ type CrowdsourcedVexingService struct { dependencyVulnRepository shared.DependencyVulnRepository trustedEntityRepository shared.TrustedEntityRepository rbacProvider shared.RBACProvider + cveRelationshipService shared.CVERelationshipService } func mapOrg(org models.Org, orgTrustscore float64, ownerID string, organizationMemberIDs []string) crowdsourcevexing.Organization { @@ -62,7 +63,16 @@ func mapAsset(asset models.Asset) crowdsourcevexing.Asset { } } -func NewCrowdsourcedVexingService(vexRuleRepository shared.VEXRuleRepository, systemVexRuleRepository shared.SystemVEXRuleRepository, organisationRepository shared.OrganizationRepository, projectRepository shared.ProjectRepository, assetVersionRepository shared.AssetVersionRepository, dependencyVulnRepository shared.DependencyVulnRepository, trustedEntityRepository shared.TrustedEntityRepository, rbacProvider shared.RBACProvider) *CrowdsourcedVexingService { +func NewCrowdsourcedVexingService(vexRuleRepository shared.VEXRuleRepository, + systemVexRuleRepository shared.SystemVEXRuleRepository, + organisationRepository shared.OrganizationRepository, + projectRepository shared.ProjectRepository, + assetVersionRepository shared.AssetVersionRepository, + dependencyVulnRepository shared.DependencyVulnRepository, + trustedEntityRepository shared.TrustedEntityRepository, + rbacProvider shared.RBACProvider, + cveRelationshipService shared.CVERelationshipService, +) *CrowdsourcedVexingService { return &CrowdsourcedVexingService{ vexRuleRepository: vexRuleRepository, systemVexRuleRepository: systemVexRuleRepository, @@ -72,6 +82,7 @@ func NewCrowdsourcedVexingService(vexRuleRepository shared.VEXRuleRepository, sy dependencyVulnRepository: dependencyVulnRepository, trustedEntityRepository: trustedEntityRepository, rbacProvider: rbacProvider, + cveRelationshipService: cveRelationshipService, } } @@ -166,17 +177,36 @@ func (s *CrowdsourcedVexingService) Recommend(ctx shared.Context, tx shared.DB, } func (s *CrowdsourcedVexingService) RecommendSystemVEXRule(ctx shared.Context, tx shared.DB, cveID string, dependencyPath []string) (models.SystemVEXRule, error) { - rules, err := s.systemVexRuleRepository.FindByCVE(ctx.Request().Context(), tx, cveID) + requestCtx := ctx.Request().Context() + cveAliasMap, err := s.cveRelationshipService.CreateAliasRelationshipMapBatch(requestCtx, tx, []string{cveID}) + if err != nil { + slog.Info("No aliases for CVE, continuing collection of systemvexrules without aliases") + } + cveAliasArray := []string{cveID} + for alias := range cveAliasMap[cveID] { + cveAliasArray = append(cveAliasArray, alias) + } + rules, err := s.systemVexRuleRepository.FindByCVEBatch(requestCtx, tx, cveAliasArray) if err != nil { return models.SystemVEXRule{}, err } validRules := utils.Filter(rules, func(rule models.SystemVEXRule) bool { return dtos.PathPattern(rule.PathPattern).MatchesSuffix(dependencyPath) }) + nonAliasDetected := false +outer: + for i := range validRules { + for j := range validRules { + if !s.cveRelationshipService.IsAlias(validRules[i].CVEID, validRules[j].CVEID, cveAliasMap) { + nonAliasDetected = true + break outer + } + } + } if len(validRules) == 0 { return models.SystemVEXRule{}, fmt.Errorf("no system VEX rules found for CVE: %s", cveID) } - if len(validRules) > 1 { + if len(validRules) > 1 && nonAliasDetected { return models.SystemVEXRule{}, fmt.Errorf("multiple system VEX rules found for CVE: %s, cannot determine which one to recommend", cveID) } return validRules[0], nil diff --git a/services/cve_relationship_service.go b/services/cve_relationship_service.go new file mode 100644 index 000000000..d50bf4960 --- /dev/null +++ b/services/cve_relationship_service.go @@ -0,0 +1,55 @@ +package services + +import ( + "context" + + "github.com/l3montree-dev/devguard/dtos" + "github.com/l3montree-dev/devguard/shared" +) + +type CVERelationshipService struct { + cveRelationshipRepository shared.CVERelationshipRepository +} + +func NewCVERelationshipService(cveRelationshipRepository shared.CVERelationshipRepository) *CVERelationshipService { + return &CVERelationshipService{ + cveRelationshipRepository: cveRelationshipRepository, + } +} + +func (s *CVERelationshipService) CreateAliasRelationshipMapBatch(ctx context.Context, tx shared.DB, cveIDs []string) (map[string]map[string]struct{}, error) { + cveRelationships, err := s.cveRelationshipRepository.FindCrossRelationshipsBatch(ctx, tx, cveIDs) + if err != nil { + return nil, err + } + + cveAliasMap := make(map[string]map[string]struct{}) + + for _, rel := range cveRelationships { + if rel.RelationshipType != dtos.RelationshipTypeAlias { + continue + } + + if cveAliasMap[rel.SourceCVE] == nil { + cveAliasMap[rel.SourceCVE] = make(map[string]struct{}) + } + if cveAliasMap[rel.TargetCVE] == nil { + cveAliasMap[rel.TargetCVE] = make(map[string]struct{}) + } + + for alias := range cveAliasMap[rel.SourceCVE] { + cveAliasMap[alias][rel.TargetCVE] = struct{}{} + cveAliasMap[rel.TargetCVE][alias] = struct{}{} + } + + cveAliasMap[rel.SourceCVE][rel.TargetCVE] = struct{}{} + cveAliasMap[rel.TargetCVE][rel.SourceCVE] = struct{}{} + } + + return cveAliasMap, nil +} + +func (s *CVERelationshipService) IsAlias(cveSource, cveTarget string, cveMap map[string]map[string]struct{}) bool { + _, ok := cveMap[cveSource][cveTarget] + return ok +} diff --git a/services/providers.go b/services/providers.go index 54683f373..5702c4e8b 100644 --- a/services/providers.go +++ b/services/providers.go @@ -38,5 +38,9 @@ var ServiceModule = fx.Options( fx.Provide(fx.Annotate(NewDependencyProxyService, fx.As(new(shared.DependencyProxySecretService)))), fx.Provide(fx.Annotate(NewAdminService, fx.As(new(shared.AdminService)))), fx.Provide(fx.Annotate(NewCrowdsourcedVexingService, fx.As(new(shared.CrowdSourcedVexingService)))), +<<<<<<< HEAD fx.Provide(fx.Annotate(NewDBEncryptionService, fx.As(new(shared.DBEncryptionService)))), +======= + fx.Provide(fx.Annotate(NewCVERelationshipService, fx.As(new(shared.CVERelationshipService)))), +>>>>>>> f6ff53aa (Implemented Auto apply feature for systemvexrules) ) diff --git a/services/vex_rule_service.go b/services/vex_rule_service.go index b44b89219..9b0095092 100644 --- a/services/vex_rule_service.go +++ b/services/vex_rule_service.go @@ -38,11 +38,13 @@ import ( ) type VEXRuleService struct { - vexRuleRepository shared.VEXRuleRepository - systemVEXRuleRepository shared.SystemVEXRuleRepository - dependencyVulnRepository shared.DependencyVulnRepository - vulnEventRepository shared.VulnEventRepository - cveRepository shared.CveRepository + vexRuleRepository shared.VEXRuleRepository + systemVEXRuleRepository shared.SystemVEXRuleRepository + dependencyVulnRepository shared.DependencyVulnRepository + vulnEventRepository shared.VulnEventRepository + cveRepository shared.CveRepository + cveRelationshipRepository shared.CVERelationshipRepository + cveRelationshipService shared.CVERelationshipService } var _ shared.VEXRuleService = (*VEXRuleService)(nil) @@ -53,13 +55,17 @@ func NewVEXRuleService( dependencyVulnRepository shared.DependencyVulnRepository, vulnEventRepository shared.VulnEventRepository, cveRepository shared.CveRepository, + cveRelationshipRepository shared.CVERelationshipRepository, + cveRelationshipService shared.CVERelationshipService, ) *VEXRuleService { return &VEXRuleService{ - vexRuleRepository: vexRuleRepository, - systemVEXRuleRepository: systemVEXRuleRepository, - dependencyVulnRepository: dependencyVulnRepository, - vulnEventRepository: vulnEventRepository, - cveRepository: cveRepository, + vexRuleRepository: vexRuleRepository, + systemVEXRuleRepository: systemVEXRuleRepository, + dependencyVulnRepository: dependencyVulnRepository, + vulnEventRepository: vulnEventRepository, + cveRepository: cveRepository, + cveRelationshipRepository: cveRelationshipRepository, + cveRelationshipService: cveRelationshipService, } } @@ -109,8 +115,18 @@ func (s *VEXRuleService) FindByAssetVersionAndVulnID(ctx context.Context, tx sha return nil, fmt.Errorf("failed to find vulnerability: %w", err) } - // Find rules for this CVE - rules, err := s.vexRuleRepository.FindByAssetVersionAndCVE(ctx, tx, assetID, assetVersionName, vuln.CVEID) + cveAliasMap, err := s.cveRelationshipService.CreateAliasRelationshipMapBatch(ctx, tx, []string{vuln.CVEID}) + if err != nil { + slog.Info("Failed to find CVE Aliases, continuing without") + } + var cveAliases []string + for alias := range cveAliasMap[vuln.CVEID] { + cveAliases = append(cveAliases, alias) + } + cveAliases = append(cveAliases, vuln.CVEID) + + // Find rules for this CVE and aliases + rules, err := s.vexRuleRepository.FindByAssetVersionAndCVEAliases(ctx, tx, assetID, assetVersionName, cveAliases) if err != nil { return nil, err } @@ -137,7 +153,7 @@ func (s *VEXRuleService) CountMatchingVulns(ctx context.Context, tx shared.DB, r if err != nil { return 0, fmt.Errorf("failed to count matching vulns: %w", err) } - matching := matchRulesToVulns([]models.VEXRule{rule}, vulns) + matching := s.MatchRulesToVulns(ctx, tx, []models.VEXRule{rule}, vulns) return len(matching[rule.ID]), nil } @@ -155,7 +171,7 @@ func (s *VEXRuleService) CountMatchingVulnsForRules(ctx context.Context, tx shar vulns, err := s.dependencyVulnRepository.GetDependencyVulnsByAssetVersion(ctx, tx, assetVersionName, assetID, nil) - vulnsByRule := matchRulesToVulns(rules, vulns) + vulnsByRule := s.MatchRulesToVulns(ctx, tx, rules, vulns) if err != nil { return nil, fmt.Errorf("failed to count matching vulns: %w", err) } @@ -212,7 +228,7 @@ func (s *VEXRuleService) ApplyRulesToExistingForce(ctx context.Context, tx share } func (s *VEXRuleService) applyRulesToExistingInternal(ctx context.Context, tx shared.DB, rules []models.VEXRule, vulns []models.DependencyVuln, forceReapply bool) ([]models.DependencyVuln, error) { - vulnsByRule := matchRulesToVulns(rules, vulns) + vulnsByRule := s.MatchRulesToVulns(ctx, tx, rules, vulns) ruleMap := make(map[string]*models.VEXRule) for i := range rules { ruleMap[rules[i].ID] = &rules[i] @@ -286,8 +302,26 @@ func (s *VEXRuleService) ApplyRulesToExistingVulns(ctx context.Context, tx share if len(rules) == 0 { return nil, nil } + assetDeduplicationMap := make(map[string]bool) + assetTuples := []struct { + AssetID string + AssetVersionName string + }{} + + for _, rule := range rules { + assetIDString := rule.AssetID.String() + compositeKey := assetIDString + rule.AssetVersionName + if !assetDeduplicationMap[compositeKey] { + assetDeduplicationMap[compositeKey] = true + assetTuples = append(assetTuples, struct { + AssetID string + AssetVersionName string + }{AssetID: assetIDString, AssetVersionName: rule.AssetVersionName}) + } + } + // Find all vulns matching all rules at once - vulns, err := s.dependencyVulnRepository.GetAllOpenVulnsByAssetVersionNameAndAssetID(ctx, tx, nil, rules[0].AssetVersionName, rules[0].AssetID) + vulns, err := s.dependencyVulnRepository.GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch(ctx, tx, assetTuples) if err != nil { return nil, fmt.Errorf("failed to fetch existing vulns for asset: %w", err) @@ -560,6 +594,12 @@ func (s *VEXRuleService) parseVEXRulesFromOpenVEXReport(ctx context.Context, ass continue } + if componentPurl.Version == "" { + // Might SPAM logs + slog.Info("product identifier does not contain version, skipping VEX rule creation for this vuln", "statement", statement.ID, "cveID", cveID) + continue + } + if err != nil { slog.Info("failed to parse product identifier therefore no identifier present, skipping VEX rule creation for this vuln", "statement", statement.ID, "cveID", cveID) continue @@ -768,8 +808,18 @@ func matchVulnsToRules(vulns []models.DependencyVuln, rules []models.VEXRule) ma return result } -func matchRulesToVulns(rules []models.VEXRule, vulns []models.DependencyVuln) map[string][]models.DependencyVuln { +func (s *VEXRuleService) MatchRulesToVulns(ctx context.Context, tx shared.DB, rules []models.VEXRule, vulns []models.DependencyVuln) map[string][]models.DependencyVuln { result := make(map[string][]models.DependencyVuln) + // Prepare aliases + // Relationship field of rules cannot be preloaded since the preload assumes that the CVEID is the source_cve in the relationship + // Therefore it cannot find the relationships + // We try to find the relationships manually and create a many-to-many crossreference so each CVE will always find each alias + ruleCVEIDs := utils.Map(rules, func(rule models.VEXRule) string { return rule.CVEID }) + + cveAliasMap, err := s.cveRelationshipService.CreateAliasRelationshipMapBatch(ctx, tx, ruleCVEIDs) + if err != nil { + slog.Info("could not find aliases to create cross relations", "err", err) + } // Filter by each rule's cve and path pattern - only match ENABLED rules // group by cve id m := make(map[string][]models.VEXRule) @@ -778,13 +828,20 @@ func matchRulesToVulns(rules []models.VEXRule, vulns []models.DependencyVuln) ma continue } m[rule.CVEID] = append(m[rule.CVEID], rule) + // Prepare for aliases + for aliasCVEID := range cveAliasMap[rule.CVEID] { + m[aliasCVEID] = append(m[aliasCVEID], rule) + } } for _, vuln := range vulns { rulesForCVE := m[vuln.CVEID] + for _, rule := range rulesForCVE { pattern := dtos.PathPattern(rule.PathPattern) - if pattern.MatchesSuffix(vuln.VulnerabilityPath) { + if vuln.Vulnerability.AssetID == rule.AssetID && + vuln.Vulnerability.AssetVersionName == rule.AssetVersionName && + pattern.MatchesSuffix(vuln.VulnerabilityPath) { result[rule.ID] = append(result[rule.ID], vuln) } } @@ -853,6 +910,7 @@ func (s *VEXRuleService) UpdateSystemVEXRulesFromStaticSources(ctx context.Conte for _, rule := range systemVEXRules { cveKey := strings.ToLower(strings.TrimSpace(rule.CVEID)) if _, exists := existingCVEMap[cveKey]; !exists { + // Might SPAM logs slog.Info("skipping system VEX rule because CVE does not exist in database yet", "cveID", rule.CVEID, "vexSource", rule.VexSource, diff --git a/services/vex_rule_service_test.go b/services/vex_rule_service_test.go index 7d96d0544..23d1a119c 100644 --- a/services/vex_rule_service_test.go +++ b/services/vex_rule_service_test.go @@ -11,6 +11,7 @@ import ( "github.com/l3montree-dev/devguard/dtos" "github.com/l3montree-dev/devguard/mocks" "github.com/l3montree-dev/devguard/normalize" + "github.com/l3montree-dev/devguard/utils" ov "github.com/openvex/go-vex/pkg/vex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -219,10 +220,12 @@ func TestVEXRuleServiceUpdate(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.Update(context.Background(), nil, rule) assert.NoError(t, err) @@ -244,12 +247,15 @@ func TestVEXRuleServiceDelete(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Delete", mock.Anything, mock.Anything, mock.MatchedBy(func(r models.VEXRule) bool { return r.ID == "test-rule-1" })).Return(nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) + err := service.Delete(context.Background(), nil, rule) assert.NoError(t, err) @@ -265,10 +271,12 @@ func TestVEXRuleServiceDeleteByAssetVersion(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("DeleteByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.DeleteByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -298,10 +306,12 @@ func TestVEXRuleServiceFindByAssetVersion(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(rules, nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) found, err := service.FindByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -326,10 +336,12 @@ func TestVEXRuleServiceFindByID(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByID", mock.Anything, mock.Anything, "test-rule-1").Return(rule, nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) found, err := service.FindByID(context.Background(), nil, "test-rule-1") assert.NoError(t, err) @@ -380,6 +392,8 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -389,13 +403,27 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + ruleCVEIDs := utils.Map(rules, func(rule models.VEXRule) string { + return rule.CVEID + }) + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, ruleCVEIDs).Return(map[string]map[string]struct{}{ + "CVE-2024-1234": { + "GO-2024-1234": {}, + }, + "GO-2024-1234": { + "CVE-2024-1234": {}, + }, + }, nil) + + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) counts, err := service.CountMatchingVulnsForRules(context.Background(), nil, rules) assert.NoError(t, err) assert.NotNil(t, counts) assert.Len(t, counts, 2) depVulnRepo.AssertExpectations(t) + cveRelationshipService.AssertExpectations(t) } // TestVEXRuleServiceCountMatchingVulns tests counting matches for single rule @@ -432,6 +460,8 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -441,12 +471,24 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + ruleCVEIDs := []string{rule.CVEID} + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, ruleCVEIDs).Return(map[string]map[string]struct{}{ + "CVE-2024-1234": { + "GO-2024-1234": {}, + }, + "GO-2024-1234": { + "CVE-2024-1234": {}, + }, + }, nil) + + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) count, err := service.CountMatchingVulns(context.Background(), nil, rule) assert.NoError(t, err) assert.GreaterOrEqual(t, count, 0) depVulnRepo.AssertExpectations(t) + cveRelationshipService.AssertExpectations(t) } // TestVEXRuleEnabledBasedOnParanoidMode tests that VEX rules are enabled/disabled based on asset ParanoidMode @@ -485,6 +527,8 @@ func TestVEXRuleEnabledBasedOnParanoidMode(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) // Mock FindByAssetAndVexSource to return empty (no existing rules) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -495,10 +539,23 @@ func TestVEXRuleEnabledBasedOnParanoidMode(t *testing.T) { capturedRules = args.Get(2).([]models.VEXRule) }).Return(nil) - // Mock GetAllOpenVulnsByAssetVersionNameAndAssetID for ApplyRulesToExistingVulns - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) + // Mock GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch for ApplyRulesToExistingVulns + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{}, nil) + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{"CVE-2024-1234"}).Return(map[string]map[string]struct{}{}, nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) // Create a minimal VEX report with one vulnerability vexReport := createTestVexReport() @@ -514,6 +571,7 @@ func TestVEXRuleEnabledBasedOnParanoidMode(t *testing.T) { } vexRuleRepo.AssertExpectations(t) + cveRelationshipService.AssertExpectations(t) }) } } @@ -521,6 +579,7 @@ func TestVEXRuleEnabledBasedOnParanoidMode(t *testing.T) { // TestMatchRulesToVulnsOnlyMatchesEnabledRules verifies that matchRulesToVulns only matches enabled rules func TestMatchRulesToVulnsOnlyMatchesEnabledRules(t *testing.T) { assetID := uuid.New() + vexRuleService := mocks.NewVEXRuleService(t) enabledRule := models.VEXRule{ ID: "enabled-rule", @@ -557,7 +616,13 @@ func TestMatchRulesToVulnsOnlyMatchesEnabledRules(t *testing.T) { for _, r := range rules { ruleMap[r.ID] = r } - result := matchRulesToVulns(rules, vulns) + vexRuleService.On("MatchRulesToVulns", mock.Anything, mock.Anything, rules, vulns).Return(map[string][]models.DependencyVuln{ + "enabled-rule": []models.DependencyVuln{ + models.DependencyVuln{CVEID: enabledRule.CVEID}, + }, + }, nil) + + result := vexRuleService.MatchRulesToVulns(context.Background(), nil, rules, vulns) // Only the enabled rule should have matches enabledMatches := 0 @@ -573,6 +638,8 @@ func TestMatchRulesToVulnsOnlyMatchesEnabledRules(t *testing.T) { assert.Equal(t, 1, enabledMatches, "enabled rule should match one vulnerability") assert.Equal(t, 0, disabledMatches, "disabled rule should not match any vulnerabilities") + + vexRuleService.AssertExpectations(t) } // createTestVexReport creates a minimal VEX report for testing @@ -667,10 +734,41 @@ func TestApplyRulesToExistingVulnsOnlyAppliesEnabledRules(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) // Mock GetAllOpenVulnsByAssetVersionNameAndAssetID to return both vulns - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, assetVersionName, assetID). - Return([]models.DependencyVuln{vulnForEnabledRule, vulnForDisabledRule}, nil) + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{vulnForEnabledRule, vulnForDisabledRule}, nil) + + ruleCVEIDs := utils.Map([]models.VEXRule{enabledRule, disabledRule}, func(rule models.VEXRule) string { + return rule.CVEID + }) + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, ruleCVEIDs).Return(map[string]map[string]struct{}{ + "CVE-2024-1234": { + "GO-2024-1234": {}, + }, + "GO-2024-1234": { + "CVE-2024-1234": {}, + }, + "CVE-2024-5678": { + "GO-2024-5678": {}, + }, + "GO-2024-5678": { + "CVE-2024-5678": {}, + }, + }, nil) // Track which vulns get saved - only the vuln matching the enabled rule should be updated var savedVulns []models.DependencyVuln @@ -684,7 +782,7 @@ func TestApplyRulesToExistingVulnsOnlyAppliesEnabledRules(t *testing.T) { savedEvents = args.Get(2).([]models.VulnEvent) }).Return(nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) // Apply both rules (one enabled, one disabled) _, err := service.ApplyRulesToExistingVulns(context.Background(), nil, []models.VEXRule{enabledRule, disabledRule}) @@ -739,12 +837,43 @@ func TestEnablingRuleAppliesItToVulns(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) // First, try to apply the disabled rule - should not save any events - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, assetVersionName, assetID). - Return([]models.DependencyVuln{matchingVuln}, nil).Once() + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{matchingVuln}, nil).Once() + + ruleCVEIDs := utils.Map([]models.VEXRule{rule}, func(rule models.VEXRule) string { + return rule.CVEID + }) + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, ruleCVEIDs).Return(map[string]map[string]struct{}{ + "CVE-2024-1234": { + "GO-2024-1234": {}, + }, + "GO-2024-1234": { + "CVE-2024-1234": {}, + }, + "CVE-2024-5678": { + "GO-2024-5678": {}, + }, + "GO-2024-5678": { + "CVE-2024-5678": {}, + }, + }, nil) // No SaveBatchBestEffort calls expected for disabled rule _, err := service.ApplyRulesToExistingVulns(context.Background(), nil, []models.VEXRule{rule}) @@ -753,8 +882,18 @@ func TestEnablingRuleAppliesItToVulns(t *testing.T) { // Now enable the rule and apply again - this time events should be saved rule.Enabled = true - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, assetVersionName, assetID). - Return([]models.DependencyVuln{matchingVuln}, nil).Once() + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{matchingVuln}, nil).Once() // Track saved events to verify rule was applied var savedEvents []models.VulnEvent @@ -774,6 +913,7 @@ func TestEnablingRuleAppliesItToVulns(t *testing.T) { depVulnRepo.AssertExpectations(t) vulnEventRepo.AssertExpectations(t) + cveRelationshipService.AssertExpectations(t) } // TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign tests that component PURLs @@ -826,6 +966,8 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -834,9 +976,35 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { capturedRules = args.Get(2).([]models.VEXRule) }).Return(nil) - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{}, nil) + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{"CVE-2024-9999"}).Return(map[string]map[string]struct{}{ + "CVE-2024-1234": { + "GO-2024-1234": {}, + }, + "GO-2024-1234": { + "CVE-2024-1234": {}, + }, + "CVE-2024-5678": { + "GO-2024-5678": {}, + }, + "GO-2024-5678": { + "CVE-2024-5678": {}, + }, + }, nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.IngestVEX(context.Background(), nil, asset, assetVersion, vexReport) assert.NoError(t, err) @@ -867,11 +1035,12 @@ func TestParseVEXRulesInBOM_ComponentPurlWithEncodedAtSign(t *testing.T) { "middle element should be the wildcard") vexRuleRepo.AssertExpectations(t) + depVulnRepo.AssertExpectations(t) } func TestParseVEXRulesFromOpenVEXReport_SelectValidProductID(t *testing.T) { assetID := uuid.New() - service := NewVEXRuleService(nil, nil, nil, nil, nil) + service := NewVEXRuleService(nil, nil, nil, nil, nil, nil, nil) testCases := []struct { name string @@ -949,7 +1118,7 @@ func TestParseVEXRulesFromOpenVEXReport_SelectValidProductID(t *testing.T) { // VEX rule per statement. func TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements(t *testing.T) { assetID := uuid.New() - service := NewVEXRuleService(nil, nil, nil, nil, nil) + service := NewVEXRuleService(nil, nil, nil, nil, nil, nil, nil) ts := time.Now().UTC() report := &normalize.VexReportOpenVEX{ @@ -1062,6 +1231,24 @@ func TestParseVEXRulesFromOpenVEXReport_NormalAndMultipleStatements(t *testing.T // TestMatchRulesToVulns_ComponentPurlWithAtSign verifies that rules with properly // unescaped component PURLs (containing @) correctly match vulnerabilities. func TestMatchRulesToVulns_ComponentPurlWithAtSign(t *testing.T) { + vexRuleRepo := mocks.NewVEXRuleRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + dependencyVulnRepo := mocks.NewDependencyVulnRepository(t) + vulnEventRepo := mocks.NewVulnEventRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) + + vexRuleService := NewVEXRuleService( + vexRuleRepo, + systemVexRuleRepo, + dependencyVulnRepo, + vulnEventRepo, + cveRepo, + cveRelationshipRepo, + cveRelationshipService, + ) + rule := models.VEXRule{ ID: "rule-at-sign", CVEID: "CVE-2024-9999", @@ -1077,7 +1264,12 @@ func TestMatchRulesToVulns_ComponentPurlWithAtSign(t *testing.T) { ComponentPurl: "pkg:npm/@myorg/vulnerable-lib@2.0.0", } - result := matchRulesToVulns([]models.VEXRule{rule}, []models.DependencyVuln{vuln}) + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{rule.CVEID}).Return(map[string]map[string]struct{}{ + "CVE-2024-9999": {"GO-2024-9999": {}}, + "GO-2024-9999": {"CVE-2024-9999": {}}, + }, nil) + + result := vexRuleService.MatchRulesToVulns(context.Background(), nil, []models.VEXRule{rule}, []models.DependencyVuln{vuln}) assert.Len(t, result[rule.ID], 1, "rule should match the vulnerability") assert.Equal(t, "CVE-2024-9999", result[rule.ID][0].CVEID) @@ -1087,6 +1279,23 @@ func TestMatchRulesToVulns_ComponentPurlWithAtSign(t *testing.T) { // component PURL were still encoded with %40, it would NOT match vulnerability // paths that use the unescaped @ form. func TestMatchRulesToVulns_EncodedAtSignDoesNotMatch(t *testing.T) { + vexRuleRepo := mocks.NewVEXRuleRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + dependencyVulnRepo := mocks.NewDependencyVulnRepository(t) + vulnEventRepo := mocks.NewVulnEventRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) + + vexRuleService := NewVEXRuleService( + vexRuleRepo, + systemVexRuleRepo, + dependencyVulnRepo, + vulnEventRepo, + cveRepo, + cveRelationshipRepo, + cveRelationshipService, + ) // Simulate the old buggy behavior: %40 in the path pattern rule := models.VEXRule{ ID: "rule-encoded", @@ -1103,12 +1312,68 @@ func TestMatchRulesToVulns_EncodedAtSignDoesNotMatch(t *testing.T) { ComponentPurl: "pkg:npm/@myorg/vulnerable-lib@2.0.0", } - result := matchRulesToVulns([]models.VEXRule{rule}, []models.DependencyVuln{vuln}) + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{rule.CVEID}).Return(map[string]map[string]struct{}{ + "CVE-2024-9999": {"GO-2024-9999": {}}, + "GO-2024-9999": {"CVE-2024-9999": {}}, + }, nil) + + result := vexRuleService.MatchRulesToVulns(context.Background(), nil, []models.VEXRule{rule}, []models.DependencyVuln{vuln}) assert.Empty(t, result[rule.ID], "encoded %%40 in path pattern should NOT match unescaped @ in vulnerability path — this demonstrates the bug") } +func TestMatchRulesToVulns_FindsAlias(t *testing.T) { + assetID := uuid.New() + assetVersionName := "main" + vexRuleRepo := mocks.NewVEXRuleRepository(t) + systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) + dependencyVulnRepo := mocks.NewDependencyVulnRepository(t) + vulnEventRepo := mocks.NewVulnEventRepository(t) + cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) + + vexRuleService := NewVEXRuleService( + vexRuleRepo, + systemVexRuleRepo, + dependencyVulnRepo, + vulnEventRepo, + cveRepo, + cveRelationshipRepo, + cveRelationshipService, + ) + + rule := models.VEXRule{ + AssetID: assetID, + AssetVersionName: assetVersionName, + ID: "rule", + CVEID: "GO-2024-9999", + Enabled: true, + PathPattern: []string{"pkg:npm/@myorg/myapp@1.0.0", "*", "pkg:npm/@myorg/vulnerable-lib@2.0.0"}, + } + + vuln := models.DependencyVuln{ + CVEID: "CVE-2024-9999", + VulnerabilityPath: []string{"pkg:npm/@myorg/myapp@1.0.0", "pkg:npm/@myorg/vulnerable-lib@2.0.0"}, + ComponentPurl: "pkg:npm/@myorg/vulnerable-lib@2.0.0", + Vulnerability: models.Vulnerability{ + AssetID: assetID, + AssetVersionName: assetVersionName, + }, + } + + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{rule.CVEID}).Return(map[string]map[string]struct{}{ + "CVE-2024-9999": {"GO-2024-9999": {}}, + "GO-2024-9999": {"CVE-2024-9999": {}}, + }, nil) + + result := vexRuleService.MatchRulesToVulns(context.Background(), nil, []models.VEXRule{rule}, []models.DependencyVuln{vuln}) + + assert.Len(t, result[rule.ID], 1, "rule should match the vulnerability") + assert.Equal(t, "CVE-2024-9999", result[rule.ID][0].CVEID) +} + // TestMatchVulnsToRules tests the matchVulnsToRules function which maps vulnerability IDs to matching enabled VEX rules func TestMatchVulnsToRules(t *testing.T) { t.Run("matches enabled rules by CVE and path pattern", func(t *testing.T) { @@ -1304,6 +1569,8 @@ func TestParseVEXRulesInBOM_PathPatternFromProperties(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -1312,9 +1579,21 @@ func TestParseVEXRulesInBOM_PathPatternFromProperties(t *testing.T) { capturedRules = args.Get(2).([]models.VEXRule) }).Return(nil) - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{}, nil) + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{"CVE-2024-1234"}).Return(map[string]map[string]struct{}{}, nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.IngestVEX(context.Background(), nil, asset, assetVersion, vexReport) assert.NoError(t, err) @@ -1326,6 +1605,8 @@ func TestParseVEXRulesInBOM_PathPatternFromProperties(t *testing.T) { "path pattern should be parsed from the property value, not reconstructed from PURLs") vexRuleRepo.AssertExpectations(t) + depVulnRepo.AssertExpectations(t) + cveRelationshipService.AssertExpectations(t) } // TestParseVEXRulesInBOM_MultiplePathPatternProperties tests that multiple pathPattern @@ -1384,6 +1665,8 @@ func TestParseVEXRulesInBOM_MultiplePathPatternProperties(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByAssetAndVexSource", mock.Anything, mock.Anything, assetID, mock.Anything).Return([]models.VEXRule{}, nil) @@ -1392,9 +1675,21 @@ func TestParseVEXRulesInBOM_MultiplePathPatternProperties(t *testing.T) { capturedRules = args.Get(2).([]models.VEXRule) }).Return(nil) - depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetID", mock.Anything, mock.Anything, mock.Anything, "v1.0", assetID).Return([]models.DependencyVuln{}, nil) + depVulnRepo.On("GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch", mock.Anything, mock.Anything, []struct { + AssetID string + AssetVersionName string + }{ + struct { + AssetID string + AssetVersionName string + }{ + AssetID: assetID.String(), + AssetVersionName: "v1.0", + }, + }).Return([]models.DependencyVuln{}, nil) + cveRelationshipService.On("CreateAliasRelationshipMapBatch", mock.Anything, mock.Anything, []string{"CVE-2024-1234", "CVE-2024-1234"}).Return(map[string]map[string]struct{}{}, nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.IngestVEX(context.Background(), nil, asset, assetVersion, vexReport) assert.NoError(t, err) @@ -1428,10 +1723,12 @@ func TestVEXRuleServiceCreate(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.Create(context.Background(), nil, rule) assert.NoError(t, err) diff --git a/shared/common_interfaces.go b/shared/common_interfaces.go index 85e541b6b..7c2129b0f 100644 --- a/shared/common_interfaces.go +++ b/shared/common_interfaces.go @@ -269,6 +269,14 @@ type DependencyVulnRepository interface { // regardless of path. Used for applying status changes to all instances of a CVE+component combination. FindByCVEAndComponentPurl(ctx context.Context, tx DB, assetID uuid.UUID, cveID string, componentPurl string) ([]models.DependencyVuln, error) GetDirectDependencyFixedVersionByPackageName(ctx context.Context, tx DB, packageName string) (*string, error) + GetAllOpenVulnsByAssetVersionNameAndAssetIDBatch( + ctx context.Context, + tx DB, + assetTuples []struct { + AssetID string + AssetVersionName string + }, + ) ([]models.DependencyVuln, error) } type FirstPartyVulnRepository interface { @@ -337,11 +345,14 @@ type VEXRuleRepository interface { DeleteByAssetVersion(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string) error Begin(ctx context.Context) DB FindByAssetVersionAndCVE(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, cveID string) ([]models.VEXRule, error) + FindByAssetVersionAndCVEAliases(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, cveIDs []string) ([]models.VEXRule, error) } type SystemVEXRuleRepository interface { + All(ctx context.Context, tx DB) ([]models.SystemVEXRule, error) GetDB(ctx context.Context, db DB) DB FindByCVE(ctx context.Context, tx DB, cveID string) ([]models.SystemVEXRule, error) + FindByCVEBatch(ctx context.Context, tx DB, cveIDs []string) ([]models.SystemVEXRule, error) UpsertBatch(ctx context.Context, tx DB, rules []models.SystemVEXRule) error } @@ -454,6 +465,7 @@ type AssetVersionRepository interface { DeleteOldAssetVersions(ctx context.Context, tx DB, day int) (int64, error) DeleteOldAssetVersionsOfAsset(ctx context.Context, tx DB, assetID uuid.UUID, day int) (int64, error) GetAmountOfAssetVersionsInOrg(ctx context.Context, tx DB, orgID uuid.UUID) (int, error) + FindSystemVEXRuleApplicableAssetVersions(ctx context.Context, tx DB) ([]models.AssetVersion, error) } type FirstPartyVulnService interface { @@ -501,6 +513,7 @@ type VEXRuleService interface { FindByID(ctx context.Context, tx DB, id string) (models.VEXRule, error) FindByAssetVersionAndCVE(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, cveID string) ([]models.VEXRule, error) FindByAssetVersionAndVulnID(ctx context.Context, tx DB, assetID uuid.UUID, assetVersionName string, vulnID uuid.UUID) ([]models.VEXRule, error) + MatchRulesToVulns(ctx context.Context, tx DB, rules []models.VEXRule, vulns []models.DependencyVuln) map[string][]models.DependencyVuln UpdateSystemVEXRulesFromStaticSources(ctx context.Context, reports []*normalize.VexReportOpenVEX) error } @@ -508,6 +521,11 @@ type CrowdSourcedVexingService interface { Recommend(ctx Context, tx DB, vulnID uuid.UUID) (models.VEXRule, error) } +type CVERelationshipService interface { + CreateAliasRelationshipMapBatch(ctx context.Context, tx DB, cveIDs []string) (map[string]map[string]struct{}, error) + IsAlias(cveSource, cveTarget string, cveMap map[string]map[string]struct{}) bool +} + type VulnEventRepository interface { SaveBatch(ctx context.Context, tx DB, events []models.VulnEvent) error SaveBatchBestEffort(ctx context.Context, tx DB, events []models.VulnEvent) error @@ -660,6 +678,7 @@ type ComponentService interface { type CVERelationshipRepository interface { utils.Repository[string, models.CVERelationship, DB] GetRelationshipsByTargetCVEBatch(ctx context.Context, tx DB, targetCVEIDs []string) ([]models.CVERelationship, error) + FindCrossRelationshipsBatch(ctx context.Context, tx DB, assiciatedCVEIDs []string) ([]models.CVERelationship, error) } type LicenseRiskService interface { diff --git a/tests/daemon_pipeline_test.go b/tests/daemon_pipeline_test.go index 94b59d45e..230346bea 100644 --- a/tests/daemon_pipeline_test.go +++ b/tests/daemon_pipeline_test.go @@ -12,6 +12,7 @@ import ( "github.com/l3montree-dev/devguard/normalize" "github.com/package-url/packageurl-go" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // createTestAffectedComponent creates a properly populated AffectedComponent for testing @@ -819,3 +820,550 @@ func TestDaemonPipelineRiskCalculation(t *testing.T) { }) }) } + +func TestDaemonPipelineApplySystemVEXRules(t *testing.T) { + + package1 := "pkg:npm/test-package@1.0.0" + package2 := "pkg:npm/test-package@2.0.0" + lib1 := "pkg:npm/test-lib@1.0.0" + lib2 := "pkg:npm/test-lib@2.0.0" + vulnLib1 := "pkg:npm/test-vulnerableLib@1.0.0" + vulnLib2 := "pkg:npm/test-vulnerableLib@2.0.0" + + WithTestApp(t, "../initdb.sql", func(f *TestFixture) { + t.Run("should apply systemVEXRules to existing vulns if path matches", func(t *testing.T) { + org1 := f.CreateOrg("test-org-1") + project1 := f.CreateProject(org1.ID, "test-project-1") + asset1 := f.CreateAsset(project1.ID, "test-asset-1") + assetVersion1 := f.CreateAssetVersion(asset1.ID, "main", true) + + cve1 := models.CVE{ + CVE: "CVE-2025-TEST-001", + CVSS: 7.5, + } + + err := f.DB.Create(&cve1).Error + assert.NoError(t, err) + + vulnerability1 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + + err = f.DB.Create(&vulnerability1).Error + assert.NoError(t, err) + + systemVEXRule1 := models.SystemVEXRule{ + // Composite key components + CVEID: cve1.CVE, + VexSource: "https://test-cve.com", + + // Rule data + EventType: dtos.EventTypeFalsePositive, + MechanicalJustification: dtos.ComponentNotPresent, + PathPattern: dtos.PathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}), + CreatedByID: "system", + } + systemVEXRule1.SetPathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}) + + err = f.DB.Create(&systemVEXRule1).Error + assert.NoError(t, err) + + runner := f.CreateDaemonRunner() + err = runner.ApplySystemVEXRules(context.Background()) + assert.NoError(t, err) + + var createdDependencyVuln models.DependencyVuln + err = f.DB.First(&createdDependencyVuln).Error + assert.NoError(t, err) + + // Idea is, if the SystemVEXRule is properly created/applied, there should be only one DependencyVuln with the corresponding CVEID + var createdVulnEvents []models.VulnEvent + err = f.DB.Find(&createdVulnEvents, "dependency_vuln_id = ?", createdDependencyVuln.Vulnerability.ID).Error + assert.NoError(t, err) + assert.Equal(t, 1, len(createdVulnEvents)) + assert.Equal(t, dtos.ComponentNotPresent, createdVulnEvents[0].MechanicalJustification) + assert.Equal(t, "system", createdVulnEvents[0].UserID) + }) + }) + + WithTestApp(t, "../initdb.sql", func(f *TestFixture) { + t.Run("should not apply systemVEXRules to existing vulns if paths don't match", func(t *testing.T) { + org1 := f.CreateOrg("test-org-1") + project1 := f.CreateProject(org1.ID, "test-project-1") + asset1 := f.CreateAsset(project1.ID, "test-asset-1") + assetVersion1 := f.CreateAssetVersion(asset1.ID, "main", true) + + cve1 := models.CVE{ + CVE: "CVE-2025-TEST-001", + CVSS: 7.5, + } + + err := f.DB.Create(&cve1).Error + assert.NoError(t, err) + + cve2 := models.CVE{ + CVE: "CVE-2025-TEST-002", + CVSS: 3.5, + } + + err = f.DB.Create(&cve2).Error + assert.NoError(t, err) + + vulnerability2 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve2.CVE, + ComponentPurl: vulnLib2, + VulnerabilityPath: []string{package2, lib2, vulnLib2}, + Artifacts: []models.Artifact{}, + } + + err = f.DB.Create(&vulnerability2).Error + assert.NoError(t, err) + + systemVEXRule1 := models.SystemVEXRule{ + // Composite key components + CVEID: cve1.CVE, + VexSource: "https://test-cve.com", + + // Rule data + EventType: dtos.EventTypeFalsePositive, + MechanicalJustification: dtos.ComponentNotPresent, + PathPattern: dtos.PathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}), + CreatedByID: "system", + } + systemVEXRule1.SetPathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}) + + err = f.DB.Create(&systemVEXRule1).Error + assert.NoError(t, err) + + runner := f.CreateDaemonRunner() + err = runner.ApplySystemVEXRules(context.Background()) + assert.NoError(t, err) + + var createdDependencyVuln models.DependencyVuln + err = f.DB.First(&createdDependencyVuln).Error + assert.NoError(t, err) + + // This should not be applied, so there should be no DependencyVulns here + var createdVulnEvents []models.VulnEvent + err = f.DB.Find(&createdVulnEvents, "dependency_vuln_id = ?", createdDependencyVuln.Vulnerability.ID).Error + assert.NoError(t, err) + assert.Equal(t, 0, len(createdVulnEvents)) + }) + }) + WithTestApp(t, "../initdb.sql", func(f *TestFixture) { + t.Run("should apply systemVEXRules to existing vulns even with cve alias", func(t *testing.T) { + org1 := f.CreateOrg("test-org-1") + project1 := f.CreateProject(org1.ID, "test-project-1") + asset1 := f.CreateAsset(project1.ID, "test-asset-1") + assetVersion1 := f.CreateAssetVersion(asset1.ID, "main", true) + + cve1 := models.CVE{ + CVE: "CVE-2025-TEST-001", + CVSS: 7.5, + } + + err := f.DB.Create(&cve1).Error + assert.NoError(t, err) + + cve1Alias := models.CVE{ + CVE: "CVE-2025-TEST-ALIAS-001", + CVSS: 7.5, + } + + err = f.DB.Create(&cve1Alias).Error + assert.NoError(t, err) + + cveRelationship1 := models.CVERelationship{ + SourceCVE: cve1.CVE, + TargetCVE: cve1Alias.CVE, + RelationshipType: dtos.RelationshipTypeAlias, + } + + err = f.DB.Create(&cveRelationship1).Error + assert.NoError(t, err) + + vulnerabilityWithAlias := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1Alias.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + + err = f.DB.Create(&vulnerabilityWithAlias).Error + assert.NoError(t, err) + + systemVEXRule := models.SystemVEXRule{ + // Composite key components + CVEID: cve1.CVE, + VexSource: "https://test-cve.com", + + // Rule data + EventType: dtos.EventTypeFalsePositive, + MechanicalJustification: dtos.ComponentNotPresent, + PathPattern: dtos.PathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}), + CreatedByID: "system", + } + systemVEXRule.SetPathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}) + + err = f.DB.Create(&systemVEXRule).Error + assert.NoError(t, err) + + runner := f.CreateDaemonRunner() + err = runner.ApplySystemVEXRules(context.Background()) + assert.NoError(t, err) + + var createdDependencyVuln models.DependencyVuln + err = f.DB.First(&createdDependencyVuln, "cve_id = ?", cve1Alias.CVE).Error + assert.NoError(t, err) + + // Idea is, if the SystemVEXRule is properly created/applied, there should be only one DependencyVuln with the corresponding CVEID + var createdVulnEvents []models.VulnEvent + err = f.DB.Find(&createdVulnEvents, "dependency_vuln_id = ?", createdDependencyVuln.Vulnerability.ID).Error + assert.NoError(t, err) + assert.Equal(t, 1, len(createdVulnEvents)) + assert.Equal(t, dtos.ComponentNotPresent, createdVulnEvents[0].MechanicalJustification) + assert.Equal(t, "system", createdVulnEvents[0].UserID) + }) + }) + WithTestApp(t, "../initdb.sql", func(f *TestFixture) { + + t.Run("should not apply systemVEXRules to existing vulns under paranoid mode", func(t *testing.T) { + org1 := f.CreateOrg("test-org-1") + project1 := f.CreateProject(org1.ID, "test-project-1") + asset1 := models.Asset{ + Name: "test-project-1", + ProjectID: project1.ID, + ParanoidMode: true, + } + err := f.DB.Create(&asset1).Error + require.NoError(f.T, err) + assetVersion1 := f.CreateAssetVersion(asset1.ID, "main", true) + + cve1 := models.CVE{ + CVE: "CVE-2025-TEST-001", + CVSS: 7.5, + } + + err = f.DB.Create(&cve1).Error + assert.NoError(t, err) + + vulnerability1 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + + err = f.DB.Create(&vulnerability1).Error + assert.NoError(t, err) + + systemVEXRule1 := models.SystemVEXRule{ + // Composite key components + CVEID: cve1.CVE, + VexSource: "https://test-cve.com", + + // Rule data + EventType: dtos.EventTypeFalsePositive, + MechanicalJustification: dtos.ComponentNotPresent, + PathPattern: dtos.PathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}), + CreatedByID: "system", + } + systemVEXRule1.SetPathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}) + + err = f.DB.Create(&systemVEXRule1).Error + assert.NoError(t, err) + + runner := f.CreateDaemonRunner() + err = runner.ApplySystemVEXRules(context.Background()) + assert.NoError(t, err) + + var createdDependencyVuln models.DependencyVuln + err = f.DB.First(&createdDependencyVuln).Error + assert.NoError(t, err) + + // This should not be applied, so there should be no DependencyVulns here + var createdVulnEvents []models.VulnEvent + err = f.DB.Find(&createdVulnEvents, "dependency_vuln_id = ?", createdDependencyVuln.Vulnerability.ID).Error + assert.NoError(t, err) + assert.Equal(t, 0, len(createdVulnEvents)) + }) + }) + WithTestApp(t, "../initdb.sql", func(f *TestFixture) { + t.Run("systemVEXRules should be applied for all assets that are not in paranoid mode and all vulns that are matching", func(t *testing.T) { + org1 := f.CreateOrg("test-org-1") + project1 := f.CreateProject(org1.ID, "test-project-1") + asset1 := f.CreateAsset(project1.ID, "test-asset-1") + assetVersion1 := f.CreateAssetVersion(asset1.ID, "main", true) + + org2 := f.CreateOrg("test-org-2") + project2 := f.CreateProject(org2.ID, "test-project-2") + asset2 := f.CreateAsset(project2.ID, "test-asset-2") + assetVersion2 := f.CreateAssetVersion(asset2.ID, "main", true) + + org3 := f.CreateOrg("test-org-3") + project3 := f.CreateProject(org3.ID, "test-project-3") + asset3 := models.Asset{ + Name: "test-project-3", + ProjectID: project3.ID, + ParanoidMode: true, + } + err := f.DB.Create(&asset3).Error + require.NoError(f.T, err) + assetVersion3 := f.CreateAssetVersion(asset3.ID, "main", true) + + org4 := f.CreateOrg("test-org-4") + project4 := f.CreateProject(org4.ID, "test-project-4") + asset4 := f.CreateAsset(project4.ID, "test-asset-4") + assetVersion4 := f.CreateAssetVersion(asset4.ID, "main", true) + + // Create CVEs + cve1 := models.CVE{ + CVE: "CVE-2025-TEST-001", + CVSS: 7.5, + } + err = f.DB.Create(&cve1).Error + assert.NoError(t, err) + + cve1Alias := models.CVE{ + CVE: "CVE-2025-TEST-ALIAS-001", + CVSS: 7.5, + } + err = f.DB.Create(&cve1Alias).Error + assert.NoError(t, err) + + cve2 := models.CVE{ + CVE: "CVE-2025-TEST-002", + CVSS: 3.5, + } + err = f.DB.Create(&cve2).Error + assert.NoError(t, err) + + cve2Alias1 := models.CVE{ + CVE: "CVE-2025-TEST-ALIAS-102", + CVSS: 4.9, + } + err = f.DB.Create(&cve2Alias1).Error + assert.NoError(t, err) + + cve2Alias2 := models.CVE{ + CVE: "CVE-2025-TEST-ALIAS-202", + CVSS: 4.9, + } + err = f.DB.Create(&cve2Alias2).Error + assert.NoError(t, err) + + // Create CVE Aliases + cveRelationship1 := models.CVERelationship{ + SourceCVE: "CVE-2025-TEST-001", + TargetCVE: "CVE-2025-TEST-ALIAS-001", + RelationshipType: dtos.RelationshipTypeAlias, + } + err = f.DB.Create(&cveRelationship1).Error + assert.NoError(t, err) + + cveRelationship2 := models.CVERelationship{ + SourceCVE: "CVE-2025-TEST-002", + TargetCVE: "CVE-2025-TEST-ALIAS-102", + RelationshipType: dtos.RelationshipTypeAlias, + } + err = f.DB.Create(&cveRelationship2).Error + assert.NoError(t, err) + + cveRelationship3 := models.CVERelationship{ + SourceCVE: "CVE-2025-TEST-002", + TargetCVE: "CVE-2025-TEST-ALIAS-202", + RelationshipType: dtos.RelationshipTypeAlias, + } + err = f.DB.Create(&cveRelationship3).Error + assert.NoError(t, err) + + vulnerability1 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability1).Error + assert.NoError(t, err) + + vulnerability2 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset1.ID, + AssetVersionName: assetVersion1.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve2.CVE, + ComponentPurl: vulnLib2, + VulnerabilityPath: []string{package2, lib2, vulnLib2}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability2).Error + assert.NoError(t, err) + + vulnerability3 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset2.ID, + AssetVersionName: assetVersion2.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability3).Error + assert.NoError(t, err) + + // Exists in different asset as vulnnerabilty1 and has a different CVE + vulnerability4 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset2.ID, + AssetVersionName: assetVersion2.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve2.CVE, + ComponentPurl: vulnLib2, + VulnerabilityPath: []string{package2, lib2, vulnLib2}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability4).Error + assert.NoError(t, err) + + vulnerability5 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset2.ID, + AssetVersionName: assetVersion2.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve2Alias1.CVE, + ComponentPurl: vulnLib2, + VulnerabilityPath: []string{package2, lib2, vulnLib2}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability5).Error + assert.NoError(t, err) + + // Vuln created for asset3, this should not be matched since the asset is in paranoid mode + vulnerability6 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset3.ID, + AssetVersionName: assetVersion3.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib1, vulnLib1}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability6).Error + assert.NoError(t, err) + + // Vuln created for asset4, alias test + vulnerability7 := models.DependencyVuln{ + Vulnerability: models.Vulnerability{ + AssetID: asset4.ID, + AssetVersionName: assetVersion4.Name, + State: dtos.VulnStateOpen, + LastDetected: time.Now(), + }, + CVEID: cve1Alias.CVE, + ComponentPurl: vulnLib1, + VulnerabilityPath: []string{package1, lib2, vulnLib1}, + Artifacts: []models.Artifact{}, + } + err = f.DB.Create(&vulnerability7).Error + assert.NoError(t, err) + + //Create SystemVEXRules + systemVEXRule1 := models.SystemVEXRule{ + // Composite key components + CVEID: cve1.CVE, + VexSource: "https://test-cve.com", + + // Rule data + EventType: dtos.EventTypeFalsePositive, + MechanicalJustification: dtos.ComponentNotPresent, + PathPattern: dtos.PathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}), + CreatedByID: "system", + } + systemVEXRule1.SetPathPattern(dtos.PathPattern{package1, dtos.PathPatternWildcard, vulnLib1}) + + err = f.DB.Create(&systemVEXRule1).Error + assert.NoError(t, err) + + runner := f.CreateDaemonRunner() + err = runner.ApplySystemVEXRules(context.Background()) + assert.NoError(t, err) + + var createdDependencyVulns []models.DependencyVuln + err = f.DB.Find(&createdDependencyVulns).Error + assert.NoError(t, err) + + var createdRels []models.CVERelationship + err = f.DB.Find(&createdRels).Error + assert.NoError(t, err) + + createdDependencyVulnsIDs := utils.Map(createdDependencyVulns, func(vuln models.DependencyVuln) uuid.UUID { + return vuln.ID + }) + + // This should not be applied, so there should be no DependencyVulns here + var createdVulnEvents []models.VulnEvent + err = f.DB.Find(&createdVulnEvents, "dependency_vuln_id IN (?)", createdDependencyVulnsIDs).Error + assert.NoError(t, err) + assert.Equal(t, 3, len(createdVulnEvents)) + + resultsMap := make(map[string]models.VulnEvent) + for _, ve := range createdVulnEvents { + resultsMap[ve.DependencyVulnID.String()] = ve + } + + for _, dv := range createdDependencyVulns { + if _, ok := resultsMap[dv.ID.String()]; !ok { + continue + } + assert.Equal(t, dtos.ComponentNotPresent, resultsMap[dv.ID.String()].MechanicalJustification) + assert.Equal(t, "system", resultsMap[dv.ID.String()].UserID) + } + }) + }) + +} diff --git a/tests/fx_test_app.go b/tests/fx_test_app.go index 1c40843f7..5057ab349 100644 --- a/tests/fx_test_app.go +++ b/tests/fx_test_app.go @@ -103,6 +103,7 @@ type TestApp struct { VexRuleRepository shared.VEXRuleRepository ExternalReferenceRepository shared.ExternalReferenceRepository VexRuleService shared.VEXRuleService + SystemVEXRuleRepository shared.SystemVEXRuleRepository // Access Control RBACProvider shared.RBACProvider diff --git a/tests/fx_test_helpers.go b/tests/fx_test_helpers.go index b3b0a49c6..340bc6aed 100644 --- a/tests/fx_test_helpers.go +++ b/tests/fx_test_helpers.go @@ -89,6 +89,7 @@ func (f *TestFixture) CreateOrg(name string) models.Org { org := models.Org{ Name: name, Description: "Test Organization", + Slug: name, } err := f.DB.Create(&org).Error require.NoError(f.T, err) @@ -172,6 +173,7 @@ func (f *TestFixture) CreateDaemonRunner() *daemons.DaemonRunner { f.App.VulnDBService, f.App.VexRuleService, f.App.FixedVersionResolver, + f.App.SystemVEXRuleRepository, ) } diff --git a/tests/vex_test.go b/tests/vex_test.go index 64edafed8..0f4228a3a 100644 --- a/tests/vex_test.go +++ b/tests/vex_test.go @@ -37,10 +37,12 @@ func TestVEXRuleServiceUpdate(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.Update(context.Background(), nil, rule) assert.NoError(t, err) @@ -63,12 +65,14 @@ func TestVEXRuleServiceDelete(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Delete", mock.Anything, mock.Anything, mock.MatchedBy(func(r models.VEXRule) bool { return r.ID == "test-rule-1" })).Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.Delete(context.Background(), nil, rule) assert.NoError(t, err) @@ -85,10 +89,12 @@ func TestVEXRuleServiceDeleteByAssetVersion(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("DeleteByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.DeleteByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -119,10 +125,12 @@ func TestVEXRuleServiceFindByAssetVersion(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByAssetVersion", mock.Anything, mock.Anything, assetID, "v1.0").Return(rules, nil) - service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) found, err := service.FindByAssetVersion(context.Background(), nil, assetID, "v1.0") assert.NoError(t, err) @@ -148,10 +156,12 @@ func TestVEXRuleServiceFindByID(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("FindByID", mock.Anything, mock.Anything, "test-rule-1").Return(rule, nil) - service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) found, err := service.FindByID(context.Background(), nil, "test-rule-1") assert.NoError(t, err) @@ -203,6 +213,8 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -212,7 +224,7 @@ func TestVEXRuleServiceCountMatchingVulnsForRules(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) counts, err := service.CountMatchingVulnsForRules(context.Background(), nil, rules) assert.NoError(t, err) @@ -256,6 +268,8 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) depVulnRepo.On("GetDependencyVulnsByAssetVersion", mock.Anything, @@ -265,7 +279,7 @@ func TestVEXRuleServiceCountMatchingVulns(t *testing.T) { mock.Anything, ).Return(vulns, nil) - service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) count, err := service.CountMatchingVulns(context.Background(), nil, rule) assert.NoError(t, err) @@ -291,10 +305,12 @@ func TestVEXRuleServiceCreate(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) vexRuleRepo.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) err := service.Create(context.Background(), nil, rule) assert.NoError(t, err) @@ -336,6 +352,8 @@ func TestApplyRulesToExistingIdempotent(t *testing.T) { vulnEventRepo := mocks.NewVulnEventRepository(t) systemVexRuleRepo := mocks.NewSystemVEXRuleRepository(t) cveRepo := mocks.NewCveRepository(t) + cveRelationshipRepo := mocks.NewCVERelationshipRepository(t) + cveRelationshipService := mocks.NewCVERelationshipService(t) // Track how many events are saved across all calls var totalEventsSaved int @@ -347,7 +365,7 @@ func TestApplyRulesToExistingIdempotent(t *testing.T) { }). Return(nil) - service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo) + service := services.NewVEXRuleService(vexRuleRepo, systemVexRuleRepo, depVulnRepo, vulnEventRepo, cveRepo, cveRelationshipRepo, cveRelationshipService) // First call — should create 1 event vulns := []models.DependencyVuln{vuln} diff --git a/transformer/vex_rule_transformer.go b/transformer/vex_rule_transformer.go index c618cce30..5ec4e4d29 100644 --- a/transformer/vex_rule_transformer.go +++ b/transformer/vex_rule_transformer.go @@ -55,7 +55,7 @@ func VEXRuleToRecommendationDTO(rule models.VEXRule) dtos.VexRuleRecommendation } func VEXRuleToSystemVEXRule(rule models.VEXRule) models.SystemVEXRule { - return models.SystemVEXRule{ + transformedRule := models.SystemVEXRule{ ID: rule.ID, // Composite key components @@ -71,10 +71,12 @@ func VEXRuleToSystemVEXRule(rule models.VEXRule) models.SystemVEXRule { CreatedAt: rule.CreatedAt, UpdatedAt: rule.UpdatedAt, } + transformedRule.SetPathPattern(rule.PathPattern) + return transformedRule } func SystemVEXRuleToVEXRule(systemRule models.SystemVEXRule) models.VEXRule { - return models.VEXRule{ + transformedRule := models.VEXRule{ ID: systemRule.ID, // Composite key components @@ -96,4 +98,6 @@ func SystemVEXRuleToVEXRule(systemRule models.SystemVEXRule) models.VEXRule { Enabled: false, } + transformedRule.SetPathPattern(systemRule.PathPattern) + return transformedRule } From 8e5a9cca3a4f870ad0b7e25a94b1c2e6d541baa0 Mon Sep 17 00:00:00 2001 From: Dennis Tuan Anh Quach <83472928+Dboy0ZDev@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:06:27 +0200 Subject: [PATCH 10/10] Added missing merge resolve --- services/providers.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/services/providers.go b/services/providers.go index 5702c4e8b..ac72b96a1 100644 --- a/services/providers.go +++ b/services/providers.go @@ -38,9 +38,6 @@ var ServiceModule = fx.Options( fx.Provide(fx.Annotate(NewDependencyProxyService, fx.As(new(shared.DependencyProxySecretService)))), fx.Provide(fx.Annotate(NewAdminService, fx.As(new(shared.AdminService)))), fx.Provide(fx.Annotate(NewCrowdsourcedVexingService, fx.As(new(shared.CrowdSourcedVexingService)))), -<<<<<<< HEAD fx.Provide(fx.Annotate(NewDBEncryptionService, fx.As(new(shared.DBEncryptionService)))), -======= fx.Provide(fx.Annotate(NewCVERelationshipService, fx.As(new(shared.CVERelationshipService)))), ->>>>>>> f6ff53aa (Implemented Auto apply feature for systemvexrules) )