Skip to content

Commit 0a48e6e

Browse files
authored
Merge pull request #1899 from l3montree-dev/fix/webhook
Fixes webhook integration and improves
2 parents 1e72d52 + cecf3a0 commit 0a48e6e

5 files changed

Lines changed: 135 additions & 190 deletions

File tree

CLAUDE.md

Lines changed: 0 additions & 36 deletions
This file was deleted.

controllers/webhook_controller.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,9 @@ func (w *WebhookController) HandleEvent(ctx context.Context, event any) error {
303303
//send sbom
304304
if err := client.SendSBOM(ctx, *event.SBOM, event.Org, event.Project, event.Asset, event.AssetVersion, event.Artifact); err != nil {
305305
slog.Error("failed to send SBOM to webhook", "webhookID", webhook.ID, "err", err)
306+
} else {
307+
slog.Info("webhook sent", "eventType", "sbom", "webhookID", webhook.ID, "org", event.Org.Name)
306308
}
307-
slog.Info("SBOM sent to webhook", "webhookID", webhook.ID)
308309
}
309310
}
310311
case shared.FirstPartyVulnsDetectedEvent:
@@ -323,8 +324,9 @@ func (w *WebhookController) HandleEvent(ctx context.Context, event any) error {
323324
//send vulnerability
324325
if err := client.SendFirstPartyVulnerabilities(ctx, vulns, event.Org, event.Project, event.Asset, event.AssetVersion); err != nil {
325326
slog.Error("failed to send vulnerability to webhook", "webhookID", webhook.ID, "err", err)
327+
} else {
328+
slog.Info("webhook sent", "eventType", "firstPartyVulnerabilities", "webhookID", webhook.ID, "org", event.Org.Name)
326329
}
327-
slog.Info("Vulnerability sent to webhook", "webhookID", webhook.ID)
328330
}
329331
}
330332

@@ -344,8 +346,9 @@ func (w *WebhookController) HandleEvent(ctx context.Context, event any) error {
344346
//send vulnerability
345347
if err := client.SendDependencyVulnerabilities(ctx, vulns, event.Org, event.Project, event.Asset, event.AssetVersion, event.Artifact); err != nil {
346348
slog.Error("failed to send vulnerability to webhook", "webhookID", webhook.ID, "err", err)
349+
} else {
350+
slog.Info("webhook sent", "eventType", "dependencyVulnerabilities", "webhookID", webhook.ID, "org", event.Org.Name)
347351
}
348-
slog.Info("Vulnerability sent to webhook", "webhookID", webhook.ID)
349352
}
350353
}
351354
}

integrations/providers.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package integrations
1717

1818
import (
19+
"github.com/l3montree-dev/devguard/controllers"
1920
"github.com/l3montree-dev/devguard/integrations/githubint"
2021
"github.com/l3montree-dev/devguard/integrations/gitlabint"
2122
"github.com/l3montree-dev/devguard/integrations/jiraint"
@@ -38,8 +39,8 @@ var Module = fx.Options(
3839

3940
// Aggregated Third Party Integration
4041
fx.Provide(fx.Annotate(
41-
func(externalUserRepository shared.ExternalUserRepository, gitlabIntegration *gitlabint.GitlabIntegration, githubIntegration *githubint.GithubIntegration, jiraIntegration *jiraint.JiraIntegration) shared.IntegrationAggregate {
42-
return NewThirdPartyIntegrations(externalUserRepository, githubIntegration, jiraIntegration, gitlabIntegration)
42+
func(externalUserRepository shared.ExternalUserRepository, gitlabIntegration *gitlabint.GitlabIntegration, githubIntegration *githubint.GithubIntegration, jiraIntegration *jiraint.JiraIntegration, webhookIntegration *controllers.WebhookController) shared.IntegrationAggregate {
43+
return NewThirdPartyIntegrations(externalUserRepository, githubIntegration, jiraIntegration, gitlabIntegration, webhookIntegration)
4344
},
4445
fx.As(new(shared.IntegrationAggregate)),
4546
)),

services/webhook_service.go

Lines changed: 68 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,18 @@ const (
5151
)
5252

5353
type webhookClient struct {
54-
URL string
55-
Secret *string
56-
httpClient *http.Client
54+
URL string
55+
Secret *string
56+
httpClient *http.Client
57+
retryDelays []time.Duration
5758
}
5859

5960
func NewWebhookService(url string, secret *string) *webhookClient {
6061
return &webhookClient{
61-
URL: url,
62-
Secret: secret,
63-
httpClient: &http.Client{Transport: utils.EgressTransport},
62+
URL: url,
63+
Secret: secret,
64+
httpClient: &http.Client{Transport: utils.EgressTransport},
65+
retryDelays: []time.Duration{1 * time.Second, 5 * time.Second, 10 * time.Second},
6466
}
6567
}
6668

@@ -73,198 +75,123 @@ func (c *webhookClient) CreateRequest(ctx context.Context, method, url string, b
7375
ctx, cancel := context.WithTimeout(ctx, 120*time.Second)
7476
defer cancel()
7577

76-
// Retry logic with delays: 1s, 5s, 10s
77-
retryDelays := []time.Duration{1 * time.Second, 5 * time.Second, 10 * time.Second}
78-
79-
var resp *http.Response
78+
var (
79+
resp *http.Response
80+
lastErr error
81+
)
82+
83+
for i, delay := range c.retryDelays {
84+
// Drain and close the previous iteration's body so the connection can be reused.
85+
if resp != nil {
86+
_, _ = io.Copy(io.Discard, resp.Body)
87+
resp.Body.Close()
88+
resp = nil
89+
}
8090

81-
for i, delay := range retryDelays {
8291
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(bodyBytes))
8392
if err != nil {
8493
return nil, err
8594
}
86-
8795
if c.Secret != nil {
8896
req.Header.Set("X-Webhook-Secret", *c.Secret)
8997
}
90-
9198
req.Header.Set("Content-Type", "application/json")
9299

93-
resp, err = c.httpClient.Do(req)
100+
resp, lastErr = c.httpClient.Do(req)
94101

95-
if err == nil && resp != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 {
102+
// Don't retry on 2xx or permanent 4xx — only 408/429 are retryable in the 4xx range.
103+
if lastErr == nil && resp.StatusCode < 500 &&
104+
resp.StatusCode != http.StatusRequestTimeout &&
105+
resp.StatusCode != http.StatusTooManyRequests {
96106
return resp, nil
97107
}
98108

99-
if i == len(retryDelays)-1 {
100-
return nil, fmt.Errorf("webhook request failed with no response")
109+
if i == len(c.retryDelays)-1 {
110+
break
101111
}
102112

103-
time.Sleep(delay)
113+
select {
114+
case <-ctx.Done():
115+
if resp != nil {
116+
_, _ = io.Copy(io.Discard, resp.Body)
117+
resp.Body.Close()
118+
}
119+
return nil, ctx.Err()
120+
case <-time.After(delay):
121+
}
104122
}
105123

106-
// This should never be reached due to the break condition above
107-
return nil, fmt.Errorf("unexpected end of retry loop")
108-
124+
if lastErr != nil {
125+
// http.Client.Do can return a non-nil response together with an error
126+
// (e.g. CheckRedirect failures). Drain and close so the connection isn't leaked.
127+
if resp != nil {
128+
_, _ = io.Copy(io.Discard, resp.Body)
129+
resp.Body.Close()
130+
}
131+
return nil, lastErr
132+
}
133+
return resp, nil
109134
}
110135

111-
func (c *webhookClient) SendSBOM(ctx context.Context, SBOM cdx.BOM, org shared.OrgObject, project shared.ProjectObject, asset shared.AssetObject, assetVersion shared.AssetVersionObject, artifact shared.ArtifactObject) error {
112-
136+
func (c *webhookClient) send(ctx context.Context, webhookType WebhookType, payload any, org shared.OrgObject, project shared.ProjectObject, asset shared.AssetObject, assetVersion shared.AssetVersionObject, artifact shared.ArtifactObject) error {
113137
body := WebhookStruct{
114138
Organization: org,
115139
Project: project,
116140
Asset: asset,
117141
AssetVersion: assetVersion,
118-
Payload: SBOM,
119-
Type: WebhookTypeSBOM,
142+
Payload: payload,
143+
Type: webhookType,
120144
Artifact: artifact,
121145
}
122146

123147
var buf bytes.Buffer
124-
err := json.NewEncoder(&buf).Encode(body)
125-
if err != nil {
148+
if err := json.NewEncoder(&buf).Encode(body); err != nil {
126149
return err
127150
}
128151

129152
resp, err := c.CreateRequest(ctx, "POST", c.URL, &buf)
130153
if err != nil {
131154
return err
132155
}
133-
if resp == nil {
134-
return fmt.Errorf("received nil response when sending SBOM")
135-
}
136156
defer resp.Body.Close()
137-
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
138-
return fmt.Errorf("failed to send SBOM, status: %s", resp.Status)
139-
}
140157

158+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
159+
return fmt.Errorf("webhook %s failed, status: %s", webhookType, resp.Status)
160+
}
141161
return nil
142162
}
143163

144-
func (c *webhookClient) SendFirstPartyVulnerabilities(ctx context.Context, vuln []dtos.FirstPartyVulnDTO, org shared.OrgObject, project shared.ProjectObject, asset shared.AssetObject, assetVersion shared.AssetVersionObject) error {
145-
return nil
146-
147-
/*body := WebhookStruct{
148-
Organization: org,
149-
Project: project,
150-
Asset: asset,
151-
AssetVersion: assetVersion,
152-
Payload: vuln,
153-
Type: WebhookTypeFirstPartyVulnerabilities,
154-
}
155-
156-
var buf bytes.Buffer
157-
err := json.NewEncoder(&buf).Encode(body)
158-
if err != nil {
159-
return err
160-
}
161-
162-
resp, err := c.CreateRequest("POST", c.URL, &buf)
163-
if err != nil {
164-
return err
165-
}
166-
defer resp.Body.Close()
167-
if resp.StatusCode != http.StatusOK {
168-
return fmt.Errorf("failed to send vulnerability, status: %s,", resp.Status)
169-
}
164+
func (c *webhookClient) SendSBOM(ctx context.Context, SBOM cdx.BOM, org shared.OrgObject, project shared.ProjectObject, asset shared.AssetObject, assetVersion shared.AssetVersionObject, artifact shared.ArtifactObject) error {
165+
return c.send(ctx, WebhookTypeSBOM, SBOM, org, project, asset, assetVersion, artifact)
166+
}
170167

171-
return nil*/
168+
func (c *webhookClient) SendFirstPartyVulnerabilities(ctx context.Context, vuln []dtos.FirstPartyVulnDTO, org shared.OrgObject, project shared.ProjectObject, asset shared.AssetObject, assetVersion shared.AssetVersionObject) error {
169+
return c.send(ctx, WebhookTypeFirstPartyVulnerabilities, vuln, org, project, asset, assetVersion, shared.ArtifactObject{})
172170
}
173171

174172
func (c *webhookClient) SendDependencyVulnerabilities(ctx context.Context, vuln []dtos.DependencyVulnDTO, org shared.OrgObject, project shared.ProjectObject, asset shared.AssetObject, assetVersion shared.AssetVersionObject, artifact shared.ArtifactObject) error {
175-
176-
body := WebhookStruct{
177-
Organization: org,
178-
Project: project,
179-
Asset: asset,
180-
AssetVersion: assetVersion,
181-
Payload: vuln,
182-
Artifact: artifact,
183-
Type: WebhookTypeDependencyVulnerabilities,
184-
}
185-
186-
var buf bytes.Buffer
187-
err := json.NewEncoder(&buf).Encode(body)
188-
if err != nil {
189-
return err
190-
}
191-
192-
resp, err := c.CreateRequest(ctx, "POST", c.URL, &buf)
193-
if err != nil {
194-
return err
195-
}
196-
if resp == nil {
197-
return fmt.Errorf("received nil response when sending dependency vulnerabilities")
198-
}
199-
defer resp.Body.Close()
200-
if resp.StatusCode != http.StatusOK {
201-
return fmt.Errorf("failed to send vulnerability, status: %s", resp.Status)
202-
}
203-
204-
return nil
173+
return c.send(ctx, WebhookTypeDependencyVulnerabilities, vuln, org, project, asset, assetVersion, artifact)
205174
}
206175

207176
func (c *webhookClient) SendTest(ctx context.Context, org shared.OrgObject, project shared.ProjectObject, asset shared.AssetObject, assetVersion shared.AssetVersionObject, payloadType TestPayloadType) error {
177+
payload, webhookType := testPayload(payloadType)
178+
return c.send(ctx, webhookType, payload, org, project, asset, assetVersion, shared.ArtifactObject{})
179+
}
208180

209-
var payload any
210-
var webhookType WebhookType
211-
181+
func testPayload(payloadType TestPayloadType) (any, WebhookType) {
212182
switch payloadType {
213-
case TestPayloadTypeEmpty:
214-
payload = map[string]any{
215-
"message": "This is a test webhook from DevGuard",
216-
"timestamp": time.Now().UTC().Format(time.RFC3339),
217-
}
218-
webhookType = WebhookTypeTest
219-
220183
case TestPayloadTypeSampleSBOM:
221-
payload = createSampleSBOM()
222-
webhookType = WebhookTypeSBOM
223-
184+
return createSampleSBOM(), WebhookTypeSBOM
224185
case TestPayloadTypeSampleDependencyVulns:
225-
payload = createSampleDependencyVulns()
226-
webhookType = WebhookTypeDependencyVulnerabilities
227-
186+
return createSampleDependencyVulns(), WebhookTypeDependencyVulnerabilities
228187
case TestPayloadTypeSampleFirstPartyVulns:
229-
payload = createSampleFirstPartyVulns()
230-
webhookType = WebhookTypeFirstPartyVulnerabilities
231-
188+
return createSampleFirstPartyVulns(), WebhookTypeFirstPartyVulnerabilities
232189
default:
233-
payload = map[string]any{
190+
return map[string]any{
234191
"message": "This is a test webhook from DevGuard",
235192
"timestamp": time.Now().UTC().Format(time.RFC3339),
236-
}
237-
webhookType = WebhookTypeTest
193+
}, WebhookTypeTest
238194
}
239-
240-
body := WebhookStruct{
241-
Organization: org,
242-
Project: project,
243-
Asset: asset,
244-
AssetVersion: assetVersion,
245-
Payload: payload,
246-
Type: webhookType,
247-
}
248-
249-
var buf bytes.Buffer
250-
err := json.NewEncoder(&buf).Encode(body)
251-
if err != nil {
252-
return err
253-
}
254-
255-
resp, err := c.CreateRequest(ctx, "POST", c.URL, &buf)
256-
if err != nil {
257-
return err
258-
}
259-
if resp == nil {
260-
return fmt.Errorf("received nil response when sending test webhook")
261-
}
262-
defer resp.Body.Close()
263-
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
264-
return nil // Success
265-
}
266-
267-
return fmt.Errorf("failed to send test webhook, status: %s", resp.Status)
268195
}
269196

270197
func createSampleSBOM() cdx.BOM {

0 commit comments

Comments
 (0)