Skip to content

Commit db9c22a

Browse files
committed
Implement webhook integration and enhance SBOM update functionality
Signed-off-by: Rafi <refaei.shikho@hotmail.com>
1 parent 9666f6a commit db9c22a

10 files changed

Lines changed: 183 additions & 31 deletions

File tree

internal/core/assetversion/asset_version_service.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -419,11 +419,14 @@ func buildBomRefMap(bom normalize.SBOM) map[string]cdx.Component {
419419
return res
420420
}
421421

422-
func (s *service) UpdateSBOM(assetVersion models.AssetVersion, scannerID string, sbom normalize.SBOM) error {
422+
func (s *service) UpdateSBOM(assetVersion models.AssetVersion, scannerID string, sbom normalize.SBOM) (bool, error) {
423+
424+
sbomUpdated := false
425+
423426
// load the asset components
424427
assetComponents, err := s.componentRepository.LoadComponents(nil, assetVersion.Name, assetVersion.AssetID, "")
425428
if err != nil {
426-
return errors.Wrap(err, "could not load asset components")
429+
return sbomUpdated, errors.Wrap(err, "could not load asset components")
427430
}
428431

429432
existingComponentPurls := make(map[string]bool)
@@ -513,11 +516,12 @@ func (s *service) UpdateSBOM(assetVersion models.AssetVersion, scannerID string,
513516

514517
// make sure, that the components exist
515518
if err := s.componentRepository.CreateBatch(nil, componentsSlice); err != nil {
516-
return err
519+
return sbomUpdated, err
517520
}
518521

519-
if err = s.componentRepository.HandleStateDiff(nil, assetVersion.Name, assetVersion.AssetID, assetComponents, dependencies, scannerID); err != nil {
520-
return err
522+
sbomUpdated, err = s.componentRepository.HandleStateDiff(nil, assetVersion.Name, assetVersion.AssetID, assetComponents, dependencies, scannerID)
523+
if err != nil {
524+
return sbomUpdated, err
521525
}
522526

523527
// update the license information in the background
@@ -532,7 +536,7 @@ func (s *service) UpdateSBOM(assetVersion models.AssetVersion, scannerID string,
532536
}
533537
}()
534538

535-
return nil
539+
return sbomUpdated, nil
536540
}
537541

538542
func (s *service) BuildSBOM(assetVersion models.AssetVersion, version string, organizationName string, components []models.ComponentDependency) *cdx.BOM {

internal/core/common_interfaces.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ type ComponentRepository interface {
127127
LoadPathToComponent(tx DB, assetVersionName string, assetID uuid.UUID, pURL string, scannerID string) ([]models.ComponentDependency, error)
128128
SaveBatch(tx DB, components []models.Component) error
129129
FindByPurl(tx DB, purl string) (models.Component, error)
130-
HandleStateDiff(tx DB, assetVersionName string, assetID uuid.UUID, oldState []models.ComponentDependency, newState []models.ComponentDependency, scannerID string) error
130+
HandleStateDiff(tx DB, assetVersionName string, assetID uuid.UUID, oldState []models.ComponentDependency, newState []models.ComponentDependency, scannerID string) (bool, error)
131131
GetDependencyCountPerScanner(assetVersionName string, assetID uuid.UUID) (map[string]int, error)
132132
GetLicenseDistribution(tx DB, assetVersionName string, assetID uuid.UUID, scannerID string) (map[string]int, error)
133133
}
@@ -252,7 +252,7 @@ type AssetVersionService interface {
252252
BuildVeX(asset models.Asset, assetVersion models.AssetVersion, orgName string, dependencyVulns []models.DependencyVuln) *cdx.BOM
253253
GetAssetVersionsByAssetID(assetID uuid.UUID) ([]models.AssetVersion, error)
254254
HandleFirstPartyVulnResult(asset models.Asset, assetVersion *models.AssetVersion, sarifScan common.SarifResult, scannerID string, userID string) (int, int, []models.FirstPartyVuln, error)
255-
UpdateSBOM(assetVersion models.AssetVersion, scannerID string, sbom normalize.SBOM) error
255+
UpdateSBOM(assetVersion models.AssetVersion, scannerID string, sbom normalize.SBOM) (bool, error)
256256
HandleScanResult(asset models.Asset, assetVersion *models.AssetVersion, vulns []models.VulnInPackage, scannerID string, userID string) (opened []models.DependencyVuln, closed []models.DependencyVuln, newState []models.DependencyVuln, err error)
257257
BuildOpenVeX(asset models.Asset, assetVersion models.AssetVersion, organizationSlug string, dependencyVulns []models.DependencyVuln) vex.VEX
258258
}
@@ -328,7 +328,7 @@ type JiraIntegrationRepository interface {
328328
type WebhookIntegrationRepository interface {
329329
Save(tx DB, model *models.WebhookIntegration) error
330330
Read(id uuid.UUID) (models.WebhookIntegration, error)
331-
FindByOrganizationID(orgID uuid.UUID) ([]models.WebhookIntegration, error)
331+
FindByOrgIDAndProjectID(orgID uuid.UUID, projectID uuid.UUID) ([]models.WebhookIntegration, error)
332332
Delete(tx DB, id uuid.UUID) error
333333
GetClientByIntegrationID(integrationID uuid.UUID) (models.WebhookIntegration, error)
334334
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright 2025 l3montree GmbH.
2+
// SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
package webhook
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"time"
13+
14+
cdx "github.com/CycloneDX/cyclonedx-go"
15+
)
16+
17+
type webhookClient struct {
18+
URL string
19+
Secret *string
20+
}
21+
22+
func NewWebhookClient(url string, secret *string) *webhookClient {
23+
return &webhookClient{
24+
URL: url,
25+
Secret: secret,
26+
}
27+
}
28+
29+
func (c *webhookClient) CreateRequest(method, url string, body io.Reader) (*http.Response, error) {
30+
31+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
32+
defer cancel()
33+
34+
req, err := http.NewRequestWithContext(ctx, method, url, body)
35+
if err != nil {
36+
return nil, err
37+
}
38+
39+
if c.Secret != nil {
40+
req.Header.Set("X-DevGuard-Token", *c.Secret)
41+
}
42+
43+
req.Header.Set("Content-Type", "application/json")
44+
45+
return http.DefaultClient.Do(req)
46+
}
47+
48+
func (c *webhookClient) SendSBOM(SBOM cdx.BOM) error {
49+
var buf bytes.Buffer
50+
err := cdx.NewBOMEncoder(&buf, cdx.BOMFileFormatJSON).Encode(&SBOM)
51+
if err != nil {
52+
return err
53+
}
54+
55+
resp, err := c.CreateRequest("POST", c.URL, &buf)
56+
if err != nil {
57+
return err
58+
}
59+
defer resp.Body.Close()
60+
if resp.StatusCode != http.StatusOK {
61+
body, _ := io.ReadAll(resp.Body)
62+
return fmt.Errorf("failed to send SBOM, status: %s, body: %s", resp.Status, body)
63+
}
64+
65+
return nil
66+
}

internal/core/integrations/webhook/webhook_integration.go

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/l3montree-dev/devguard/internal/core"
1313
"github.com/l3montree-dev/devguard/internal/database/models"
1414
"github.com/l3montree-dev/devguard/internal/database/repositories"
15+
"github.com/labstack/echo/v4"
1516
)
1617

1718
type WebhookIntegration struct {
@@ -103,6 +104,7 @@ func (w *WebhookIntegration) TestAndSave(ctx core.Context) error {
103104
Secret string `json:"secret"`
104105
SbomEnabled bool `json:"sbomEnabled"`
105106
VulnEnabled bool `json:"vulnEnabled"`
107+
ProjectID string `json:"projectId"` // Optional, can be empty if not associated with a project
106108
}
107109

108110
if err := ctx.Bind(&data); err != nil {
@@ -112,11 +114,14 @@ func (w *WebhookIntegration) TestAndSave(ctx core.Context) error {
112114
return ctx.JSON(400, "url is required")
113115
}
114116

115-
/* projectID := uuid.Nil
116-
if ok := core.HasProject(ctx); ok {
117-
project := core.GetProject(ctx)
118-
projectID = project.ID
119-
} */
117+
var projectID *uuid.UUID
118+
if data.ProjectID != "" {
119+
parsedProjectID, err := uuid.Parse(data.ProjectID)
120+
if err != nil {
121+
return ctx.JSON(400, "invalid project ID format")
122+
}
123+
projectID = &parsedProjectID
124+
}
120125

121126
webhookIntegration := &models.WebhookIntegration{
122127
Name: &data.Name,
@@ -126,7 +131,7 @@ func (w *WebhookIntegration) TestAndSave(ctx core.Context) error {
126131
SbomEnabled: data.SbomEnabled,
127132
VulnEnabled: data.VulnEnabled,
128133
OrgID: core.GetOrg(ctx).GetID(),
129-
/* ProjectID: &projectID, */
134+
ProjectID: projectID, // Set project ID if available
130135
}
131136

132137
if err := w.webhookRepository.Save(nil, webhookIntegration); err != nil {
@@ -144,7 +149,43 @@ func (w *WebhookIntegration) TestAndSave(ctx core.Context) error {
144149
})
145150
}
146151

152+
func (w *WebhookIntegration) getWebhooks(ctx echo.Context) ([]models.WebhookIntegration, error) {
153+
orgID := core.GetOrg(ctx).GetID()
154+
project := core.GetProject(ctx)
155+
156+
webhooks, err := w.webhookRepository.FindByOrgIDAndProjectID(orgID, project.ID)
157+
if err != nil {
158+
slog.Error("failed to find webhooks", "err", err)
159+
return nil, err
160+
}
161+
162+
return webhooks, nil
163+
}
164+
147165
func (w *WebhookIntegration) HandleEvent(event any) error {
166+
167+
switch event := event.(type) {
168+
case core.SBOMCreatedEvent:
169+
170+
webhooks, err := w.getWebhooks(event.Ctx)
171+
if err != nil {
172+
slog.Error("failed to find webhooks for SBOM created event", "err", err)
173+
return err
174+
}
175+
176+
for _, webhook := range webhooks {
177+
client := NewWebhookClient(webhook.URL, webhook.Secret)
178+
if webhook.SbomEnabled {
179+
//send sbom
180+
if err := client.SendSBOM(event.SBOM); err != nil {
181+
slog.Error("failed to send SBOM to webhook", "webhookID", webhook.ID, "err", err)
182+
return err
183+
}
184+
slog.Info("SBOM sent to webhook", "webhookID", webhook.ID)
185+
}
186+
}
187+
}
188+
148189
return nil
149190
}
150191

internal/core/thirdparty_integration_events.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package core
22

33
import (
4+
cdx "github.com/CycloneDX/cyclonedx-go"
45
"github.com/l3montree-dev/devguard/internal/database/models"
56
)
67

@@ -13,3 +14,8 @@ type VulnEvent struct {
1314
Ctx Context
1415
Event models.VulnEvent
1516
}
17+
18+
type SBOMCreatedEvent struct {
19+
Ctx Context
20+
SBOM cdx.BOM
21+
}

internal/core/vulndb/scan/scan_controller.go

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ type FirstPartyScanResponse struct {
7777
FirstPartyVulns []vuln.FirstPartyVulnDTO `json:"firstPartyVulns"`
7878
}
7979

80-
func (s *HTTPController) DependencyVulnScan(c core.Context, bom normalize.SBOM) (ScanResponse, error) {
80+
func (s *HTTPController) DependencyVulnScan(c core.Context, bom normalize.SBOM) (bool, ScanResponse, error) {
8181
monitoring.DependencyVulnScanAmount.Inc()
8282
startTime := time.Now()
8383
defer func() {
@@ -103,21 +103,24 @@ func (s *HTTPController) DependencyVulnScan(c core.Context, bom normalize.SBOM)
103103
assetVersion, err := s.assetVersionRepository.FindOrCreate(assetVersionName, asset.ID, tag == "1", utils.EmptyThenNil(defaultBranch))
104104
if err != nil {
105105
slog.Error("could not find or create asset version", "err", err)
106-
return scanResults, err
106+
return false, scanResults, err
107107
}
108108

109109
scannerID := c.Request().Header.Get("X-Scanner")
110110
if scannerID == "" {
111111
slog.Error("no X-Scanner header found")
112-
return scanResults, fmt.Errorf("no X-Scanner header found")
112+
return false, scanResults, fmt.Errorf("no X-Scanner header found")
113113
}
114114

115115
// update the sbom in the database in parallel
116-
if err := s.assetVersionService.UpdateSBOM(assetVersion, scannerID, normalizedBom); err != nil {
116+
sbomUpdated, err := s.assetVersionService.UpdateSBOM(assetVersion, scannerID, normalizedBom)
117+
if err != nil {
117118
slog.Error("could not update sbom", "err", err)
118-
return scanResults, err
119+
return false, scanResults, err
119120
}
120-
return s.ScanNormalizedSBOM(org, project, asset, assetVersion, normalizedBom, scannerID, userID)
121+
122+
scanResponse, scanErr := s.ScanNormalizedSBOM(org, project, asset, assetVersion, normalizedBom, scannerID, userID)
123+
return sbomUpdated, scanResponse, scanErr
121124
}
122125

123126
func (s *HTTPController) ScanNormalizedSBOM(org models.Org, project models.Project, asset models.Asset, assetVersion models.AssetVersion, normalizedBom normalize.SBOM, scannerID string, userID string) (ScanResponse, error) {
@@ -234,10 +237,20 @@ func (s *HTTPController) ScanDependencyVulnFromProject(c core.Context) error {
234237
return err
235238
}
236239

237-
scanResults, err := s.DependencyVulnScan(c, normalize.FromCdxBom(bom, true))
240+
sbomUpdated, scanResults, err := s.DependencyVulnScan(c, normalize.FromCdxBom(bom, true))
238241
if err != nil {
239242
return err
240243
}
244+
if sbomUpdated {
245+
thirdPartyIntegrations := core.GetThirdPartyIntegration(c)
246+
if err = thirdPartyIntegrations.HandleEvent(core.SBOMCreatedEvent{
247+
Ctx: c,
248+
SBOM: *bom,
249+
}); err != nil {
250+
slog.Error("could not handle manual mitigation event", "err", err)
251+
}
252+
253+
}
241254
return c.JSON(200, scanResults)
242255
}
243256

@@ -261,10 +274,20 @@ func (s *HTTPController) ScanSbomFile(c core.Context) error {
261274
return err
262275
}
263276

264-
scanResults, err := s.DependencyVulnScan(c, normalize.FromCdxBom(bom, true))
277+
sbomUpdated, scanResults, err := s.DependencyVulnScan(c, normalize.FromCdxBom(bom, true))
265278
if err != nil {
266279
return err
267280
}
281+
if sbomUpdated {
282+
thirdPartyIntegrations := core.GetThirdPartyIntegration(c)
283+
if err = thirdPartyIntegrations.HandleEvent(core.SBOMCreatedEvent{
284+
Ctx: c,
285+
SBOM: *bom,
286+
}); err != nil {
287+
slog.Error("could not handle manual mitigation event", "err", err)
288+
}
289+
290+
}
268291
return c.JSON(200, scanResults)
269292

270293
}

internal/database/models/project_model.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ type Project struct {
3939

4040
ExternalEntityID *string `json:"externalEntityId" gorm:"uniqueIndex:unique_external_entity;"`
4141
ExternalEntityProviderID *string `json:"externalEntityProviderId" gorm:"uniqueIndex:unique_external_entity;"`
42+
43+
Webhooks []WebhookIntegration `json:"webhooks" gorm:"foreignKey:ProjectID;"`
4244
}
4345

4446
func (m Project) TableName() string {

internal/database/models/webhook_model.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,8 @@ type WebhookIntegration struct {
1919
Org Org `json:"org" gorm:"foreignKey:OrgID;constraint:OnDelete:CASCADE;"`
2020
OrgID uuid.UUID `json:"orgId" gorm:"column:org_id"`
2121

22-
/*
23-
ProjectID *uuid.UUID `json:"projectId" gorm:"column:project_id"`
24-
Project *Project `json:"project" gorm:"foreignKey:ProjectID;constraint:OnDelete:CASCADE;"`
25-
*/
22+
ProjectID *uuid.UUID `json:"projectId" gorm:"column:project_id;nullable"`
23+
Project *Project `json:"project" gorm:"foreignKey:ProjectID;constraint:OnDelete:CASCADE;"`
2624
}
2725

2826
func (WebhookIntegration) TableName() string {

internal/database/repositories/component_repository.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,10 @@ func (c *componentRepository) FindByPurl(tx core.DB, purl string) (models.Compon
313313
return component, err
314314
}
315315

316-
func (c *componentRepository) HandleStateDiff(tx core.DB, assetVersionName string, assetID uuid.UUID, oldState []models.ComponentDependency, newState []models.ComponentDependency, scannerID string) error {
316+
func (c *componentRepository) HandleStateDiff(tx core.DB, assetVersionName string, assetID uuid.UUID, oldState []models.ComponentDependency, newState []models.ComponentDependency, scannerID string) (bool, error) {
317+
318+
stateChanged := false
319+
317320
comparison := utils.CompareSlices(oldState, newState, func(dep models.ComponentDependency) string {
318321
return utils.SafeDereference(dep.ComponentPurl) + "->" + dep.DependencyPurl
319322
})
@@ -322,7 +325,11 @@ func (c *componentRepository) HandleStateDiff(tx core.DB, assetVersionName strin
322325
added := comparison.OnlyInB
323326
needToBeChanged := comparison.InBoth
324327

325-
return c.GetDB(tx).Transaction(func(tx *gorm.DB) error {
328+
if len(removed) > 0 || len(added) > 0 || len(needToBeChanged) > 0 {
329+
stateChanged = true
330+
}
331+
332+
return stateChanged, c.GetDB(tx).Transaction(func(tx *gorm.DB) error {
326333
//We remove the scanner id from all components in removed and if it was the only scanner id we remove the component
327334
toDelete, toSave := diffComponents(tx, c, removed, scannerID)
328335

internal/database/repositories/webhook_repository.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,18 @@ func NewWebhookRepository(db core.DB) *webhookRepository {
2424
Repository: newGormRepository[uuid.UUID, models.WebhookIntegration](db),
2525
}
2626
}
27-
func (r *webhookRepository) FindByOrganizationID(orgID uuid.UUID) ([]models.WebhookIntegration, error) {
27+
func (r *webhookRepository) FindByOrgIDAndProjectID(orgID uuid.UUID, projectID uuid.UUID) ([]models.WebhookIntegration, error) {
2828
var integrations []models.WebhookIntegration
29-
if err := r.db.Find(&integrations, "org_id = ?", orgID).Error; err != nil {
29+
30+
query := r.db.Where("organization_id = ? AND project_id IS NULL", orgID).Or("organization_id = ? AND project_id = ?", orgID, projectID)
31+
32+
if err := query.Find(&integrations).Error; err != nil {
3033
return nil, err
3134
}
35+
3236
return integrations, nil
3337
}
38+
3439
func (r *webhookRepository) GetClientByIntegrationID(integrationID uuid.UUID) (models.WebhookIntegration, error) {
3540
var integration models.WebhookIntegration
3641
if err := r.db.First(&integration, "id = ?", integrationID).Error; err != nil {

0 commit comments

Comments
 (0)