Skip to content

Commit 0438728

Browse files
committed
implements: #37
1 parent c7ffd80 commit 0438728

10 files changed

Lines changed: 393 additions & 2 deletions

internal/core/asset/asset_dto.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ type AssetDTO struct {
4343
CVSSAutomaticTicketThreshold *float64 `json:"cvssAutomaticTicketThreshold"`
4444
RiskAutomaticTicketThreshold *float64 `json:"riskAutomaticTicketThreshold"`
4545

46+
VulnAutoReopenAfterDays *int `json:"vulnAutoReopenAfterDays"`
47+
4648
BadgeSecret *uuid.UUID `json:"badgeSecret"`
4749
WebhookSecret *uuid.UUID `json:"webhookSecret"`
4850

@@ -81,6 +83,8 @@ func toDTO(asset models.Asset) AssetDTO {
8183
CVSSAutomaticTicketThreshold: asset.CVSSAutomaticTicketThreshold,
8284
RiskAutomaticTicketThreshold: asset.RiskAutomaticTicketThreshold,
8385

86+
VulnAutoReopenAfterDays: asset.VulnAutoReopenAfterDays,
87+
8488
AssetVersions: asset.AssetVersions,
8589

8690
ExternalEntityProviderID: asset.ExternalEntityProviderID,
@@ -168,6 +172,8 @@ type PatchRequest struct {
168172

169173
ConfigFiles *map[string]any `json:"configFiles"`
170174

175+
VulnAutoReopenAfterDays *int `json:"vulnAutoReopenAfterDays"`
176+
171177
WebhookSecret *string `json:"webhookSecret"`
172178
BadgeSecret *string `json:"badgeSecret"`
173179
}
@@ -246,5 +252,10 @@ func (assetPatch *PatchRequest) applyToModel(asset *models.Asset) bool {
246252
}
247253
}
248254

255+
if assetPatch.VulnAutoReopenAfterDays != nil {
256+
updated = true
257+
asset.VulnAutoReopenAfterDays = assetPatch.VulnAutoReopenAfterDays
258+
}
259+
249260
return updated
250261
}

internal/core/common_interfaces.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
cdx "github.com/CycloneDX/cyclonedx-go"
2424
"github.com/google/uuid"
2525
"github.com/l3montree-dev/devguard/internal/common"
26+
2627
"github.com/l3montree-dev/devguard/internal/core/normalize"
2728
"github.com/l3montree-dev/devguard/internal/database/models"
2829
"github.com/labstack/echo/v4"
@@ -149,6 +150,7 @@ type DependencyVulnRepository interface {
149150
GetDependencyVulnsByDefaultAssetVersion(tx DB, assetID uuid.UUID, scannerID string) ([]models.DependencyVuln, error)
150151
ListUnfixedByAssetAndAssetVersionAndScannerID(assetVersionName string, assetID uuid.UUID, scannerID string) ([]models.DependencyVuln, error)
151152
GetHintsInOrganizationForVuln(tx DB, orgID uuid.UUID, pURL string, cveID string) (common.DependencyVulnHints, error)
153+
GetAllByAssetIDAndState(tx DB, assetID uuid.UUID, state models.VulnState, durationSinceStateChange time.Duration) ([]models.DependencyVuln, error)
152154
}
153155

154156
type FirstPartyVulnRepository interface {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (C) 2025 l3montree GmbH
2+
//
3+
// This program is free software: you can redistribute it and/or modify
4+
// it under the terms of the GNU Affero General Public License as
5+
// published by the Free Software Foundation, either version 3 of the
6+
// License, or (at your option) any later version.
7+
//
8+
// This program is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU Affero General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU Affero General Public License
14+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
16+
package daemon
17+
18+
import (
19+
"fmt"
20+
"log/slog"
21+
"time"
22+
23+
"github.com/l3montree-dev/devguard/internal/core"
24+
"github.com/l3montree-dev/devguard/internal/database/models"
25+
"github.com/l3montree-dev/devguard/internal/database/repositories"
26+
)
27+
28+
func AutoReopenAcceptedVulnerabilities(db core.DB) error {
29+
30+
dependencyVulnRepository := repositories.NewDependencyVulnRepository(db)
31+
assetRepository := repositories.NewAssetRepository(db)
32+
33+
assets, err := assetRepository.All()
34+
if err != nil {
35+
return err
36+
}
37+
38+
for _, asset := range assets {
39+
// check if the asset has auto-reopen enabled
40+
if asset.VulnAutoReopenAfterDays == nil {
41+
continue
42+
}
43+
44+
// convert days to time.Duration
45+
reopenAfterDuration := time.Duration(*asset.VulnAutoReopenAfterDays) * 24 * time.Hour
46+
47+
// get all closed/accepted vulnerabilities for the asset version
48+
vulnerabilities, err := dependencyVulnRepository.GetAllByAssetIDAndState(nil, asset.ID, models.VulnStateAccepted, reopenAfterDuration)
49+
if err != nil {
50+
return err
51+
}
52+
53+
// create a new vulnerability event for each vulnerability
54+
events := make([]models.VulnEvent, 0, len(vulnerabilities))
55+
for _, vuln := range vulnerabilities {
56+
// create a new event for the vulnerability
57+
event := models.NewReopenedEvent(vuln.ID, models.VulnTypeDependencyVuln, "system", fmt.Sprintf("Automatically reopened since the vulnerability was accepted more than %d days ago", *asset.VulnAutoReopenAfterDays))
58+
events = append(events, event)
59+
60+
dependencyVulnRepository.ApplyAndSave(nil, &vuln, &event)
61+
slog.Info("reopened vulnerability since it was accepted more than the configured time", "vulnerabilityID", vuln.ID, "assetID", asset.ID, "reopenAfterDays", *asset.VulnAutoReopenAfterDays)
62+
}
63+
}
64+
65+
return nil
66+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package daemon_test
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
"time"
7+
8+
integration_tests "github.com/l3montree-dev/devguard/integrationtestutil"
9+
"github.com/l3montree-dev/devguard/internal/core"
10+
"github.com/l3montree-dev/devguard/internal/core/daemon"
11+
"github.com/l3montree-dev/devguard/internal/database/models"
12+
"github.com/l3montree-dev/devguard/internal/database/repositories"
13+
"github.com/l3montree-dev/devguard/internal/utils"
14+
"github.com/stretchr/testify/assert"
15+
)
16+
17+
func TestAutoReopenAcceptedVulnerabilities(t *testing.T) {
18+
db, terminate := integration_tests.InitDatabaseContainer("../../../initdb.sql")
19+
defer terminate()
20+
21+
err := db.AutoMigrate(
22+
&models.Org{},
23+
&models.Project{},
24+
&models.AssetVersion{},
25+
&models.Asset{},
26+
&models.ComponentDependency{},
27+
&models.Component{},
28+
&models.CVE{},
29+
&models.AffectedComponent{},
30+
&models.DependencyVuln{},
31+
&models.Exploit{},
32+
&models.VulnEvent{},
33+
&models.FirstPartyVuln{},
34+
)
35+
assert.NoError(t, err)
36+
37+
// Create test data
38+
_, project, asset, assetVersion := integration_tests.CreateOrgProjectAndAssetAssetVersion(db)
39+
40+
// Set up repositories
41+
assetRepo := repositories.NewAssetRepository(db)
42+
dependencyVulnRepo := repositories.NewDependencyVulnRepository(db)
43+
44+
t.Run("should not reopen vulnerabilities if auto-reopen is not configured", func(t *testing.T) {
45+
// Ensure asset has no auto-reopen configuration
46+
asset.VulnAutoReopenAfterDays = nil
47+
err := assetRepo.Update(db, &asset)
48+
assert.NoError(t, err)
49+
50+
// Create a vulnerability that was accepted 2 hours ago
51+
vulnerability := createTestVulnerability(t, db, asset, assetVersion, 2*time.Hour)
52+
acceptVulnerability(t, db, &vulnerability, 2*time.Hour)
53+
54+
// Run auto-reopen
55+
err = daemon.AutoReopenAcceptedVulnerabilities(db)
56+
assert.NoError(t, err)
57+
58+
// Verify vulnerability is still accepted
59+
updatedVuln, err := dependencyVulnRepo.Read(vulnerability.ID)
60+
assert.NoError(t, err)
61+
assert.Equal(t, models.VulnStateAccepted, updatedVuln.State)
62+
})
63+
64+
t.Run("should not reopen vulnerabilities that are within the time threshold", func(t *testing.T) {
65+
// Configure asset for auto-reopen after 1 day
66+
autoReopenAfterDays := 1
67+
asset.VulnAutoReopenAfterDays = &autoReopenAfterDays
68+
err := assetRepo.Update(db, &asset)
69+
assert.NoError(t, err)
70+
71+
// Create a vulnerability that was accepted 1 hour ago (within threshold)
72+
vulnerability := createTestVulnerability(t, db, asset, assetVersion, 1*time.Hour)
73+
acceptVulnerability(t, db, &vulnerability, 1*time.Hour)
74+
75+
// Run auto-reopen
76+
err = daemon.AutoReopenAcceptedVulnerabilities(db)
77+
assert.NoError(t, err)
78+
79+
// Verify vulnerability is still accepted
80+
updatedVuln, err := dependencyVulnRepo.Read(vulnerability.ID)
81+
assert.NoError(t, err)
82+
assert.Equal(t, models.VulnStateAccepted, updatedVuln.State)
83+
})
84+
85+
t.Run("should reopen vulnerabilities that exceed the time threshold", func(t *testing.T) {
86+
// Configure asset for auto-reopen after 1 day
87+
autoReopenAfterDays := 1
88+
asset.VulnAutoReopenAfterDays = &autoReopenAfterDays
89+
err := assetRepo.Update(db, &asset)
90+
assert.NoError(t, err)
91+
92+
// Create a vulnerability that was accepted 2 days ago (exceeds threshold)
93+
vulnerability := createTestVulnerability(t, db, asset, assetVersion, 48*time.Hour)
94+
acceptVulnerability(t, db, &vulnerability, 48*time.Hour)
95+
96+
// Run auto-reopen
97+
err = daemon.AutoReopenAcceptedVulnerabilities(db)
98+
assert.NoError(t, err)
99+
100+
// Verify vulnerability has been reopened
101+
updatedVuln, err := dependencyVulnRepo.Read(vulnerability.ID)
102+
assert.NoError(t, err)
103+
assert.Equal(t, models.VulnStateOpen, updatedVuln.State)
104+
105+
// Verify a reopen event was created
106+
events := updatedVuln.Events
107+
assert.NotEmpty(t, events)
108+
109+
// Find the reopen event
110+
var reopenEvent *models.VulnEvent
111+
for _, event := range events {
112+
if event.Type == models.EventTypeReopened {
113+
reopenEvent = &event
114+
break
115+
}
116+
}
117+
118+
assert.NotNil(t, reopenEvent, "Expected to find a reopen event")
119+
assert.Equal(t, "system", reopenEvent.UserID)
120+
assert.NotNil(t, reopenEvent.Justification, "Expected justification to not be nil")
121+
assert.Contains(t, *reopenEvent.Justification, "Automatically reopened")
122+
})
123+
124+
t.Run("should handle multiple assets with different configurations", func(t *testing.T) {
125+
// Create another asset with different auto-reopen configuration
126+
asset2 := models.Asset{
127+
Name: "test-asset-2",
128+
Slug: "test-asset-2",
129+
ProjectID: project.ID,
130+
Type: models.AssetTypeApplication,
131+
Description: "Test asset 2",
132+
}
133+
autoReopenAfter2Days := 2
134+
asset2.VulnAutoReopenAfterDays = &autoReopenAfter2Days
135+
err := assetRepo.Create(db, &asset2)
136+
assert.NoError(t, err)
137+
138+
assetVersion2 := models.AssetVersion{
139+
AssetID: asset2.ID,
140+
Name: "main",
141+
DefaultBranch: true,
142+
}
143+
assetVersionRepo := repositories.NewAssetVersionRepository(db)
144+
err = assetVersionRepo.Create(db, &assetVersion2)
145+
assert.NoError(t, err)
146+
147+
// Set different auto-reopen thresholds
148+
autoReopenAfter1Days := 1
149+
asset.VulnAutoReopenAfterDays = &autoReopenAfter1Days
150+
err = assetRepo.Update(db, &asset)
151+
assert.NoError(t, err)
152+
153+
// Create vulnerabilities for both assets
154+
vuln1 := createTestVulnerability(t, db, asset, assetVersion, 1*time.Hour)
155+
acceptVulnerability(t, db, &vuln1, 36*time.Hour) // Accepted 1.5 days ago
156+
157+
vuln2 := createTestVulnerability(t, db, asset2, assetVersion2, 2*time.Hour)
158+
acceptVulnerability(t, db, &vuln2, 72*time.Hour) // Accepted 3 days ago
159+
160+
// Run auto-reopen
161+
err = daemon.AutoReopenAcceptedVulnerabilities(db)
162+
assert.NoError(t, err)
163+
164+
// Verify both vulnerabilities are reopened
165+
updatedVuln1, err := dependencyVulnRepo.Read(vuln1.ID)
166+
assert.NoError(t, err)
167+
assert.Equal(t, models.VulnStateOpen, updatedVuln1.State)
168+
169+
updatedVuln2, err := dependencyVulnRepo.Read(vuln2.ID)
170+
assert.NoError(t, err)
171+
assert.Equal(t, models.VulnStateOpen, updatedVuln2.State)
172+
})
173+
}
174+
175+
// createTestVulnerability creates a test dependency vulnerability
176+
func createTestVulnerability(t *testing.T, db core.DB, asset models.Asset, assetVersion models.AssetVersion, timeAgo time.Duration) models.DependencyVuln {
177+
// Create a unique CVE ID for each test case
178+
cveID := fmt.Sprintf("CVE-2025-TEST-%d", time.Now().UnixNano())
179+
180+
// Create a test CVE
181+
cve := models.CVE{
182+
CVE: cveID,
183+
DatePublished: time.Now().Add(-24 * time.Hour),
184+
DateLastModified: time.Now().Add(-12 * time.Hour),
185+
Description: "Test vulnerability for auto-reopen testing",
186+
CVSS: 7.5,
187+
Severity: models.SeverityHigh,
188+
ExploitabilityScore: 3.9,
189+
ImpactScore: 3.6,
190+
}
191+
err := db.Create(&cve).Error
192+
assert.NoError(t, err)
193+
194+
// Create the vulnerability - ID will be auto-generated by BeforeSave hook
195+
vulnerability := models.DependencyVuln{
196+
Vulnerability: models.Vulnerability{
197+
AssetVersionName: assetVersion.Name,
198+
AssetID: asset.ID,
199+
State: models.VulnStateOpen,
200+
ScannerIDs: "test-scanner",
201+
LastDetected: time.Now().Add(-timeAgo),
202+
},
203+
CVEID: utils.Ptr(cveID),
204+
ComponentPurl: utils.Ptr("pkg:npm/test-package@1.0.0"),
205+
ComponentDepth: utils.Ptr(0),
206+
}
207+
err = db.Create(&vulnerability).Error
208+
assert.NoError(t, err)
209+
210+
return vulnerability
211+
}
212+
213+
// acceptVulnerability creates an accepted event for a vulnerability
214+
func acceptVulnerability(t *testing.T, db core.DB, vulnerability *models.DependencyVuln, timeAgo time.Duration) {
215+
// Create an accepted event using the model constructor
216+
acceptEvent := models.NewAcceptedEvent(vulnerability.CalculateHash(), models.VulnTypeDependencyVuln, "test-user", "Accepted for testing")
217+
218+
// Manually set the creation time for testing
219+
acceptEvent.CreatedAt = time.Now().Add(-timeAgo)
220+
acceptEvent.UpdatedAt = time.Now().Add(-timeAgo)
221+
222+
err := db.Create(&acceptEvent).Error
223+
assert.NoError(t, err)
224+
225+
// Update vulnerability state
226+
vulnerability.State = models.VulnStateAccepted
227+
vulnerability.LastDetected = time.Now().Add(-timeAgo)
228+
err = db.Save(vulnerability).Error
229+
assert.NoError(t, err)
230+
}

internal/core/daemon/daemon.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ func Start(db core.DB) {
131131
slog.Info("scan updated", "duration", time.Since(start))
132132
}
133133

134+
if shouldMirror(configService, "vulndb.autoReopen") {
135+
start = time.Now()
136+
// update the auto reopen
137+
if err := AutoReopenAcceptedVulnerabilities(db); err != nil {
138+
slog.Error("could not update auto reopen", "err", err)
139+
return nil
140+
}
141+
if err := markMirrored(configService, "vulndb.autoReopen"); err != nil {
142+
slog.Error("could not mark vulndb.autoReopen as mirrored", "err", err)
143+
}
144+
slog.Info("auto reopen updated", "duration", time.Since(start))
145+
}
146+
134147
// after we have a fresh vulndb we can update the dependencyVulns.
135148
// we save data inside the dependency_vulns table: ComponentDepth and ComponentFixedVersion
136149
// those need to be updated before recalculating the risk

internal/core/integrations/gitlabint/gitlab_integration.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,14 +252,14 @@ func (g *GitlabIntegration) checkIfTokenIsValid(ctx core.Context, token models.G
252252
}
253253

254254
// check if the token is valid by fetching the user
255-
user, resp, err := gitlabClient.ListGroups(ctx.Request().Context(), &gitlab.ListGroupsOptions{
255+
user, _, err := gitlabClient.ListGroups(ctx.Request().Context(), &gitlab.ListGroupsOptions{
256256
MinAccessLevel: utils.Ptr(gitlab.ReporterPermissions), // only list groups where the user has at least reporter permissions
257257
ListOptions: gitlab.ListOptions{PerPage: 1}, // we only need to check if the request is successful, so we can limit the number of results
258258
})
259259

260260
_ = user
261261
if err != nil {
262-
slog.Error("failed to get user", "err", err, "tokenHash", utils.HashString(token.AccessToken), "statusCode", resp.StatusCode)
262+
slog.Error("failed to get user", "err", err, "tokenHash", utils.HashString(token.AccessToken))
263263
return false
264264
}
265265

0 commit comments

Comments
 (0)