Skip to content

Commit 88239fc

Browse files
authored
feat(plugin): webhook plugin (chainloop-dev#1682)
Signed-off-by: Austin Arlint <[email protected]>
1 parent ac27750 commit 88239fc

File tree

5 files changed

+391
-0
lines changed

5 files changed

+391
-0
lines changed

app/controlplane/plugins/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ A Discord webhook plugin will
140140
- Get the Webhook URL from the state stored during the registration phase
141141
- Craft message to send to the Discord webhook
142142

143+
A generic webhook plugin will
144+
145+
- Get the Webhook URL from the state stored during the registration phase
146+
- Send the Attestation to the webhook, Additionally, it will send SBOM materials if on attachment phase send_sbom is set to true.
147+
143148
## How to create a new plugin
144149

145150
We offer a [starter template](https://github.com/chainloop-dev/chainloop/tree/main/app/controlplane/plugins/core/template) that can be used as baseline. Just copy it to a new folder i.e `core/my-plugin/v1` to get started.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Webhook Plugin
2+
3+
Send attestations and SBOMs to using webhooks.
4+
5+
## How to use it
6+
7+
1. To get started, you need to register the plugin in your Chainloop organization.
8+
9+
```console
10+
chainloop integration registered add webhook --name [my-registration] --opt url=[webhookURL]
11+
```
12+
13+
> **Note:** The webhook URL must be accessible from the Chainloop control plane.
14+
15+
1. Attach the integration to your workflow.
16+
17+
```console
18+
chainloop integration attached add --workflow $WID --integration $IID
19+
```
20+
21+
> **Note:** You can specify the `send_attestation` and `send_sbom` options to control what is sent to the webhook. `--opt "send_attestation=false"` will disable sending attestations, and `--opt "send_sbom=true"` will enable sending SBOMs.
22+
23+
## Registration Input Schema
24+
25+
|Field|Type|Required|Description|
26+
|---|---|---|---|
27+
|url|string|yes|Webhook URL to send payloads to|
28+
29+
```json
30+
{
31+
"$schema": "https://json-schema.org/draft/2020-12/schema",
32+
"$id": "https://github.com/chainloop-dev/chainloop/app/controlplane/plugins/core/webhook/v1/registration-request",
33+
"properties": {
34+
"url": {
35+
"type": "string",
36+
"minLength": 1,
37+
"description": "Webhook URL to send payloads to"
38+
}
39+
},
40+
"additionalProperties": false,
41+
"type": "object",
42+
"required": [
43+
"url"
44+
]
45+
}
46+
```
47+
48+
## Attachment Input Schema
49+
50+
|Field|Type|Required|Description|
51+
|---|---|---|---|
52+
|send_attestation|boolean|no|Send attestation|
53+
|send_sbom|boolean|no|Additionally send CycloneDX or SPDX Software Bill Of Materials (SBOM)|
54+
55+
```json
56+
{
57+
"$schema": "https://json-schema.org/draft/2020-12/schema",
58+
"$id": "https://github.com/chainloop-dev/chainloop/app/controlplane/plugins/core/webhook/v1/attachment-request",
59+
"properties": {
60+
"send_attestation": {
61+
"type": "boolean",
62+
"description": "Send attestation",
63+
"default": true
64+
},
65+
"send_sbom": {
66+
"type": "boolean",
67+
"description": "Additionally send CycloneDX or SPDX Software Bill Of Materials (SBOM)",
68+
"default": false
69+
}
70+
},
71+
"additionalProperties": false,
72+
"type": "object"
73+
}
74+
```
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
// Copyright 2025 The Chainloop Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package webhook
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"encoding/json"
21+
"errors"
22+
"fmt"
23+
"io/ioutil"
24+
"net/http"
25+
"net/url"
26+
27+
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
28+
"github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1"
29+
"github.com/go-kratos/kratos/v2/log"
30+
)
31+
32+
// Integration implements a generic webhook integration
33+
type Integration struct {
34+
*sdk.FanOutIntegration
35+
client *http.Client
36+
}
37+
38+
// registrationRequest defines the configuration required during registration
39+
type registrationRequest struct {
40+
URL string `json:"url" jsonschema:"minLength=1,description=Webhook URL to send payloads to"`
41+
}
42+
43+
// attachmentRequest defines the configuration required during attachment
44+
type attachmentRequest struct {
45+
SendAttestation *bool `json:"send_attestation,omitempty" jsonschema:"description=Send attestation,default=true"`
46+
SendSBOM *bool `json:"send_sbom,omitempty" jsonschema:"description=Additionally send CycloneDX or SPDX Software Bill Of Materials (SBOM),default=false"`
47+
}
48+
49+
// attachmentState defines the state stored after attachment
50+
type attachmentState struct {
51+
SendAttestation bool `json:"send_attestation"`
52+
SendSBOM bool `json:"send_sbom"`
53+
}
54+
55+
// registrationState defines the state stored after registration
56+
type registrationState struct {
57+
// No additional state needed for webhook besides the URL stored in credentials
58+
}
59+
60+
// webhookPayload defines the JSON schema for the webhook payload
61+
type webhookPayload struct {
62+
Metadata *sdk.ChainloopMetadata `json:"Metadata"`
63+
Data []byte `json:"Data"` // e.g., SBOM or attestation raw content in bytes
64+
Kind string `json:"Kind"` // e.g., "SBOM_CYCLONEDX_JSON", "ATTESTATION"
65+
}
66+
67+
// New initializes the webhook integration
68+
func New(l log.Logger) (sdk.FanOut, error) {
69+
base, err := sdk.NewFanOut(
70+
&sdk.NewParams{
71+
ID: "webhook",
72+
Version: "1.0",
73+
Description: "Send Attestation and SBOMs to a generic POST webhook URL",
74+
Logger: l,
75+
InputSchema: &sdk.InputSchema{
76+
Registration: registrationRequest{},
77+
Attachment: attachmentRequest{},
78+
},
79+
},
80+
// In addition to the attestation payload the following material types are also available
81+
sdk.WithInputMaterial(schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON),
82+
sdk.WithInputMaterial(schemaapi.CraftingSchema_Material_SBOM_SPDX_JSON),
83+
)
84+
if err != nil {
85+
return nil, fmt.Errorf("failed to create FanOut integration: %w", err)
86+
}
87+
88+
return &Integration{
89+
FanOutIntegration: base,
90+
client: &http.Client{},
91+
}, nil
92+
}
93+
94+
// Register is executed when registering the webhook integration
95+
func (i *Integration) Register(ctx context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) {
96+
i.Logger.Info("registration requested")
97+
98+
// Parse the registration payload
99+
var regReq registrationRequest
100+
if err := sdk.FromConfig(req.Payload, &regReq); err != nil {
101+
i.Logger.Errorw("failed to parse registration payload", "error", err)
102+
return nil, fmt.Errorf("invalid registration request: %w", err)
103+
}
104+
105+
// Validate the URL
106+
if err := validateURL(regReq.URL); err != nil {
107+
i.Logger.Errorw("invalid webhook URL", "error", err, "url", regReq.URL)
108+
return nil, fmt.Errorf("invalid webhook URL: %w", err)
109+
}
110+
111+
// Optionally, perform a test request to ensure the webhook URL is reachable
112+
if err := i.testWebhookURL(ctx, regReq.URL); err != nil {
113+
i.Logger.Errorw("unable to reach webhook URL", "error", err, "url", regReq.URL)
114+
return nil, fmt.Errorf("unable to reach webhook URL: %w", err)
115+
}
116+
117+
// Store the URL in credentials
118+
credentials := &sdk.Credentials{
119+
URL: regReq.URL, // Storing the URL in the URL field
120+
}
121+
122+
// No additional state needed
123+
rawConfig, err := sdk.ToConfig(&registrationState{})
124+
if err != nil {
125+
i.Logger.Errorw("failed to marshal registration state", "error", err)
126+
return nil, fmt.Errorf("marshalling configuration: %w", err)
127+
}
128+
129+
return &sdk.RegistrationResponse{
130+
Credentials: credentials,
131+
Configuration: rawConfig,
132+
}, nil
133+
}
134+
135+
// Attach is executed when attaching the webhook integration to a workflow
136+
func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) {
137+
// Parse the attachment payload
138+
var attachReq attachmentRequest
139+
if err := sdk.FromConfig(req.Payload, &attachReq); err != nil {
140+
i.Logger.Errorw("failed to parse attachment payload", "error", err)
141+
return nil, fmt.Errorf("invalid attachment request: %w", err)
142+
}
143+
144+
// Set default values if not provided
145+
sendAttestation := true
146+
if attachReq.SendAttestation != nil {
147+
sendAttestation = *attachReq.SendAttestation
148+
}
149+
150+
sendSBOM := false
151+
if attachReq.SendSBOM != nil {
152+
sendSBOM = *attachReq.SendSBOM
153+
}
154+
155+
// Store the settings in the attachment state
156+
rawConfig, err := sdk.ToConfig(&attachmentState{
157+
SendAttestation: sendAttestation,
158+
SendSBOM: sendSBOM,
159+
})
160+
if err != nil {
161+
i.Logger.Errorw("failed to marshal attachment state", "error", err)
162+
return nil, fmt.Errorf("marshalling attachment state: %w", err)
163+
}
164+
165+
return &sdk.AttachmentResponse{
166+
Configuration: rawConfig,
167+
}, nil
168+
}
169+
170+
// Execute is called when an attestation or SBOM is received
171+
func (i *Integration) Execute(ctx context.Context, req *sdk.ExecutionRequest) error {
172+
// Extract the webhook URL from credentials
173+
if req.RegistrationInfo.Credentials == nil || req.RegistrationInfo.Credentials.URL == "" {
174+
i.Logger.Error("missing webhook URL in credentials")
175+
return errors.New("missing webhook URL in credentials")
176+
}
177+
webhookURL := req.RegistrationInfo.Credentials.URL
178+
179+
// Extract the settings from the attachment state
180+
var attachState attachmentState
181+
if err := sdk.FromConfig(req.AttachmentInfo.Configuration, &attachState); err != nil {
182+
i.Logger.Errorw("invalid attachment state", "error", err)
183+
return fmt.Errorf("invalid attachment state: %w", err)
184+
}
185+
186+
// Send attestation if enabled and present
187+
if attachState.SendAttestation && req.Input.Attestation != nil {
188+
statementJSON, err := json.Marshal(req.Input.Attestation)
189+
if err != nil {
190+
i.Logger.Errorw("failed to marshal attestation", "error", err)
191+
return fmt.Errorf("marshalling attestation: %w", err)
192+
}
193+
if err := i.sendWebhook(ctx, webhookURL, "ATTESTATION", statementJSON, req.ChainloopMetadata); err != nil {
194+
i.Logger.Errorw("failed to send attestation webhook", "error", err)
195+
return err
196+
}
197+
}
198+
199+
// Send SBOM if enabled and present
200+
if attachState.SendSBOM {
201+
for _, material := range req.Input.Materials {
202+
// Ensure material type is either SBOM_CYCLONEDX_JSON or SBOM_SPDX_JSON
203+
if material.Type != schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON.String() && material.Type != schemaapi.CraftingSchema_Material_SBOM_SPDX_JSON.String() {
204+
i.Logger.Warnw("unsupported material type, skipping", "type", material.Type)
205+
continue
206+
}
207+
// Validate material content
208+
if len(material.Content) == 0 {
209+
i.Logger.Warnw("encountered SBOM with empty content, skipping", "type", material.Type)
210+
continue
211+
}
212+
213+
// Send the SBOM webhook
214+
if err := i.sendWebhook(ctx, webhookURL, material.Type, material.Content, req.ChainloopMetadata); err != nil {
215+
i.Logger.Errorw("failed to send SBOM webhook", "error", err, "type", material.Type)
216+
return err
217+
}
218+
}
219+
}
220+
221+
return nil
222+
}
223+
224+
// sendWebhook sends a webhook with the specified kind and payload
225+
func (i *Integration) sendWebhook(ctx context.Context, url, kind string, payload []byte, metadata *sdk.ChainloopMetadata) error {
226+
payloadBytes, err := json.Marshal(webhookPayload{
227+
Metadata: metadata,
228+
Data: payload,
229+
Kind: kind,
230+
})
231+
if err != nil {
232+
i.Logger.Errorw("failed to marshal webhook payload", "error", err, "kind", kind)
233+
return fmt.Errorf("marshalling webhook payload: %w", err)
234+
}
235+
236+
headers := map[string]string{
237+
"Content-Type": "application/json",
238+
}
239+
240+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(payloadBytes))
241+
if err != nil {
242+
i.Logger.Errorw("failed to create HTTP request", "error", err, "url", url)
243+
return fmt.Errorf("creating HTTP request: %w", err)
244+
}
245+
246+
for key, value := range headers {
247+
req.Header.Set(key, value)
248+
}
249+
250+
resp, err := i.client.Do(req)
251+
if err != nil {
252+
i.Logger.Errorw("failed to send HTTP request", "error", err, "url", url)
253+
return fmt.Errorf("sending HTTP request: %w", err)
254+
}
255+
defer func() {
256+
if err := resp.Body.Close(); err != nil {
257+
i.Logger.Warnw("failed to close response body", "error", err)
258+
}
259+
}()
260+
261+
// Read response body for more detailed error messages
262+
body, err := ioutil.ReadAll(resp.Body)
263+
if err != nil {
264+
i.Logger.Warnw("failed to read response body", "error", err)
265+
}
266+
267+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
268+
i.Logger.Errorw("webhook responded with non-success status code", "status", resp.StatusCode, "body", string(body))
269+
return fmt.Errorf("webhook responded with status code %d: %s", resp.StatusCode, string(body))
270+
}
271+
272+
return nil
273+
}
274+
275+
// testWebhookURL sends a test webhook using the sendWebhook method to ensure the webhook URL is reachable
276+
func (i *Integration) testWebhookURL(ctx context.Context, webhookURL string) error {
277+
// Define dummy metadata for the test
278+
dummyMetadata := &sdk.ChainloopMetadata{
279+
Workflow: &sdk.ChainloopMetadataWorkflow{Name: "test-webhook-workflow"},
280+
WorkflowRun: &sdk.ChainloopMetadataWorkflowRun{
281+
ID: "test-webhook-run",
282+
},
283+
}
284+
285+
// Define a dummy payload (empty JSON object)
286+
dummyData := []byte("{}")
287+
288+
// Define a unique kind for the test
289+
testKind := "TEST_WEBHOOK"
290+
291+
// Use sendWebhook to send the test payload
292+
if err := i.sendWebhook(ctx, webhookURL, testKind, dummyData, dummyMetadata); err != nil {
293+
return fmt.Errorf("test webhook failed: %w", err)
294+
}
295+
296+
return nil
297+
}
298+
299+
// validateURL performs basic validation of the webhook URL
300+
func validateURL(webhookURL string) error {
301+
parsedURL, err := url.ParseRequestURI(webhookURL)
302+
if err != nil {
303+
return fmt.Errorf("invalid URL format: %w", err)
304+
}
305+
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
306+
return fmt.Errorf("unsupported URL scheme: %s", parsedURL.Scheme)
307+
}
308+
return nil
309+
}

0 commit comments

Comments
 (0)