From 796b05b1d86a2f7d678c10b316b15d122e734184 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Fri, 27 Feb 2026 00:00:15 +0100 Subject: [PATCH 1/3] add masked url to config Signed-off-by: Sylwester Piskozub --- .../core/slack-webhook/v1/slack_webhook.go | 14 ++++- .../plugins/core/webhook/v1/webhook.go | 8 +-- .../plugins/sdk/v1/integrations.go | 19 ++++++ .../plugins/sdk/v1/integrations_test.go | 63 +++++++++++++++++++ 4 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 app/controlplane/plugins/sdk/v1/integrations_test.go diff --git a/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go b/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go index 765d6992f..63b854b97 100644 --- a/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go +++ b/app/controlplane/plugins/core/slack-webhook/v1/slack_webhook.go @@ -39,13 +39,18 @@ type registrationRequest struct { WebhookURL string `json:"webhook" jsonschema:"format=uri,description=URL of the slack webhook"` } +// registrationState defines the state stored after registration +type registrationState struct { + WebhookURL string `json:"webhook,omitempty"` +} + type attachmentRequest struct{} func New(l log.Logger) (sdk.FanOut, error) { base, err := sdk.NewFanOut( &sdk.NewParams{ ID: "slack-webhook", - Version: "1.0", + Version: "1.1", Description: "Send attestations to Slack", Logger: l, InputSchema: &sdk.InputSchema{ @@ -75,7 +80,14 @@ func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) return nil, fmt.Errorf("error validating a webhook: %w", err) } + // Store a masked version of the URL as non-secret config so it can be displayed for identification + config, err := sdk.ToConfig(®istrationState{WebhookURL: sdk.MaskURL(request.WebhookURL)}) + if err != nil { + return nil, fmt.Errorf("marshalling configuration: %w", err) + } + return &sdk.RegistrationResponse{ + Configuration: config, // We treat the webhook URL as a sensitive field so we store it in the credentials storage Credentials: &sdk.Credentials{Password: request.WebhookURL}, }, nil diff --git a/app/controlplane/plugins/core/webhook/v1/webhook.go b/app/controlplane/plugins/core/webhook/v1/webhook.go index 782575413..afa83ac2e 100644 --- a/app/controlplane/plugins/core/webhook/v1/webhook.go +++ b/app/controlplane/plugins/core/webhook/v1/webhook.go @@ -54,7 +54,7 @@ type attachmentState struct { // registrationState defines the state stored after registration type registrationState struct { - // No additional state needed for webhook besides the URL stored in credentials + WebhookURL string `json:"url,omitempty"` } // webhookPayload defines the JSON schema for the webhook payload @@ -69,7 +69,7 @@ func New(l log.Logger) (sdk.FanOut, error) { base, err := sdk.NewFanOut( &sdk.NewParams{ ID: "webhook", - Version: "1.0", + Version: "1.1", Description: "Send Attestation and SBOMs to a generic POST webhook URL", Logger: l, InputSchema: &sdk.InputSchema{ @@ -119,8 +119,8 @@ func (i *Integration) Register(ctx context.Context, req *sdk.RegistrationRequest URL: regReq.URL, // Storing the URL in the URL field } - // No additional state needed - rawConfig, err := sdk.ToConfig(®istrationState{}) + // Store a masked version of the URL as non-secret config so it can be displayed for identification + rawConfig, err := sdk.ToConfig(®istrationState{WebhookURL: sdk.MaskURL(regReq.URL)}) if err != nil { i.Logger.Errorw("failed to marshal registration state", "error", err) return nil, fmt.Errorf("marshalling configuration: %w", err) diff --git a/app/controlplane/plugins/sdk/v1/integrations.go b/app/controlplane/plugins/sdk/v1/integrations.go index 17fd2854d..69cdb592e 100644 --- a/app/controlplane/plugins/sdk/v1/integrations.go +++ b/app/controlplane/plugins/sdk/v1/integrations.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "sort" "github.com/invopop/jsonschema" @@ -236,6 +237,24 @@ func FromConfig(data Configuration, v any) error { return json.Unmarshal(data, v) } +// MaskURL returns a masked version of the URL suitable for display. +// It preserves the scheme and host, and shows only the last 4 characters +// of the path with the rest replaced by asterisks. +func MaskURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return "" + } + + path := u.Path + const suffixLen = 4 + if len(path) > suffixLen { + path = "****" + path[len(path)-suffixLen:] + } + + return u.Scheme + "://" + u.Host + path +} + // GenerateJSONSchema generates a flat JSON schema from a struct using https://github.com/invopop/jsonschema // We've put some limitations on the kind of input structs we support, for example: // - Nested schemas are not supported diff --git a/app/controlplane/plugins/sdk/v1/integrations_test.go b/app/controlplane/plugins/sdk/v1/integrations_test.go new file mode 100644 index 000000000..ccc833c58 --- /dev/null +++ b/app/controlplane/plugins/sdk/v1/integrations_test.go @@ -0,0 +1,63 @@ +package sdk + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMaskURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "long path is masked with last 4 visible", + raw: "https://example.com/some/long/path/with/many/segments/secret-token", + want: "https://example.com****oken", + }, + { + name: "short path is not masked", + raw: "https://example.com/sho", + want: "https://example.com/sho", + }, + { + name: "path at threshold is not masked", + raw: "https://example.com/abc", + want: "https://example.com/abc", + }, + { + name: "path above threshold is masked", + raw: "https://example.com/abcde", + want: "https://example.com****bcde", + }, + { + name: "empty string returns empty", + raw: "", + want: "", + }, + { + name: "missing host returns empty", + raw: "/just/a/path", + want: "", + }, + { + name: "webhook URL with port", + raw: "https://prod-00.westus.logic.azure.com:443/workflows/1234567890abcdef/triggers/manual/paths/invoke", + want: "https://prod-00.westus.logic.azure.com:443****voke", + }, + { + name: "no path", + raw: "https://example.com", + want: "https://example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MaskURL(tt.raw) + assert.Equal(t, tt.want, got) + }) + } +} From 33398db4eb42520191cfdb8bfe1a05bb4b79acc4 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Fri, 27 Feb 2026 00:09:38 +0100 Subject: [PATCH 2/3] lint Signed-off-by: Sylwester Piskozub --- .../plugins/sdk/v1/integrations_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/controlplane/plugins/sdk/v1/integrations_test.go b/app/controlplane/plugins/sdk/v1/integrations_test.go index ccc833c58..dcd5750eb 100644 --- a/app/controlplane/plugins/sdk/v1/integrations_test.go +++ b/app/controlplane/plugins/sdk/v1/integrations_test.go @@ -1,3 +1,18 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package sdk import ( From 25b792e779dc3cfed3ae60e738b3a697bacf06f8 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Fri, 27 Feb 2026 00:16:03 +0100 Subject: [PATCH 3/3] regenerate integrations doc Signed-off-by: Sylwester Piskozub --- devel/integrations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devel/integrations.md b/devel/integrations.md index 09d9ffc57..f0dbec0b8 100644 --- a/devel/integrations.md +++ b/devel/integrations.md @@ -13,9 +13,9 @@ Below you can find the list of currently available integrations. If you can't fi | [dependency-track](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/dependency-track/v1/README.md) | 1.7 | Send CycloneDX SBOMs to your Dependency-Track instance | SBOM_CYCLONEDX_JSON | | [discord-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/discord-webhook/v1/README.md) | 1.1 | Send attestations to Discord | | | [guac](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/guac/v1/README.md) | 1.0 | Export Attestation and SBOMs metadata to a blob storage backend so guacsec/guac can consume it | SBOM_CYCLONEDX_JSON, SBOM_SPDX_JSON | -| [slack-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/slack-webhook/v1/README.md) | 1.0 | Send attestations to Slack | | +| [slack-webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/slack-webhook/v1/README.md) | 1.1 | Send attestations to Slack | | | [smtp](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/smtp/v1/README.md) | 1.0 | Send emails with information about a received attestation | | -| [webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/webhook/v1/README.md) | 1.0 | Send Attestation and SBOMs to a generic POST webhook URL | SBOM_CYCLONEDX_JSON, SBOM_SPDX_JSON | +| [webhook](https://github.com/chainloop-dev/chainloop/blob/main/app/controlplane/plugins/core/webhook/v1/README.md) | 1.1 | Send Attestation and SBOMs to a generic POST webhook URL | SBOM_CYCLONEDX_JSON, SBOM_SPDX_JSON | ## How to use integrations