Skip to content
12 changes: 12 additions & 0 deletions daemons/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,16 @@ 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)
}

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)
}
}
43 changes: 43 additions & 0 deletions daemons/openvex_daemon.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions daemons/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type DaemonRunner struct {
maliciousPackageChecker shared.MaliciousPackageChecker
vulnDBImportService shared.VulnDBService
vexRuleService shared.VEXRuleService
systemVEXRuleRepository shared.SystemVEXRuleRepository

debugOptions DebugOptions
fixedVersionResolver shared.FixedVersionResolver
Expand Down Expand Up @@ -125,6 +126,7 @@ func NewDaemonRunner(
vulnDBImportService shared.VulnDBService,
vexRuleService shared.VEXRuleService,
fixedVersionResolver shared.FixedVersionResolver,
systemVEXRuleRepository shared.SystemVEXRuleRepository,
) *DaemonRunner {
return &DaemonRunner{
db: db,
Expand Down Expand Up @@ -156,6 +158,7 @@ func NewDaemonRunner(
vulnDBImportService: vulnDBImportService,
vexRuleService: vexRuleService,
fixedVersionResolver: fixedVersionResolver,
systemVEXRuleRepository: systemVEXRuleRepository,
}
}

Expand Down
72 changes: 72 additions & 0 deletions daemons/system_vexrule_daemon.go
Original file line number Diff line number Diff line change
@@ -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
}
36 changes: 36 additions & 0 deletions database/migrations/20260601085124_add_system_vex_rules.up.sql
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

-- 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);
56 changes: 56 additions & 0 deletions database/models/system_vex_rule_model.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
16 changes: 16 additions & 0 deletions database/repositories/asset_version_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
41 changes: 41 additions & 0 deletions database/repositories/cve_relationship_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package repositories

import (
"context"

"github.com/l3montree-dev/devguard/database/models"
"github.com/l3montree-dev/devguard/utils"
"gorm.io/gorm"
Expand All @@ -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
}
30 changes: 30 additions & 0 deletions database/repositories/dependency_vuln_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
1 change: 1 addition & 0 deletions database/repositories/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))),
Expand Down
Loading
Loading