From f22b3953b1a744fb544f27a9077d79fdb80eb242 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 21:45:55 +0000 Subject: [PATCH 1/5] feat: webhooks support --- service/go.mod | 2 + service/go.sum | 5 + service/internal/config/config.go | 14 ++ service/internal/httpservers/frontend.go | 5 + service/internal/webhooks/auth.go | 129 +++++++++++++++++ service/internal/webhooks/github.go | 164 ++++++++++++++++++++++ service/internal/webhooks/handler.go | 153 +++++++++++++++++++++ service/internal/webhooks/jsonpath.go | 42 ++++++ service/internal/webhooks/matcher.go | 167 +++++++++++++++++++++++ 9 files changed, 681 insertions(+) create mode 100644 service/internal/webhooks/auth.go create mode 100644 service/internal/webhooks/github.go create mode 100644 service/internal/webhooks/handler.go create mode 100644 service/internal/webhooks/jsonpath.go create mode 100644 service/internal/webhooks/matcher.go diff --git a/service/go.mod b/service/go.mod index 132a6ec..3d53c97 100644 --- a/service/go.mod +++ b/service/go.mod @@ -10,6 +10,7 @@ require ( connectrpc.com/connect v1.19.1 github.com/Masterminds/semver v1.5.0 github.com/MicahParks/keyfunc/v3 v3.7.0 + github.com/PaesslerAG/jsonpath v0.1.1 github.com/alexedwards/argon2id v1.0.0 github.com/bufbuild/buf v1.61.0 github.com/fsnotify/fsnotify v1.9.0 @@ -55,6 +56,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/MicahParks/jwkset v0.11.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/PaesslerAG/gval v1.0.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e // indirect diff --git a/service/go.sum b/service/go.sum index 8af88a2..9d82fe9 100644 --- a/service/go.sum +++ b/service/go.sum @@ -42,6 +42,11 @@ github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3 github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8= +github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= +github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 722e492..55b5b29 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -22,12 +22,14 @@ type Action struct { ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"` ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"` ExecOnCalendarFile string `koanf:"execOnCalendarFile"` + ExecOnWebhook []WebhookConfig `koanf:"execOnWebhook"` Triggers []string `koanf:"triggers"` MaxConcurrent int `koanf:"maxConcurrent"` MaxRate []RateSpec `koanf:"maxRate"` Arguments []ActionArgument `koanf:"arguments"` PopupOnStart string `koanf:"popupOnStart"` SaveLogs SaveLogsConfig `koanf:"saveLogs"` + EnabledExpression string `koanf:"enabledExpression"` } // ActionArgument objects appear on Actions. @@ -55,6 +57,18 @@ type RateSpec struct { Duration string `koanf:"duration"` } +// WebhookConfig defines configuration for generic webhook triggers. +type WebhookConfig struct { + Secret string `koanf:"secret"` // Optional: secret for signature verification + AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none" + AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature") + MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers + MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath") + MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters + Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions + Template string `koanf:"template"` // Optional: template name (e.g., "github-push", "github-pr") +} + // Entity represents a "thing" that can have multiple actions associated with it. // for example, a media player with a start and stop action. type EntityFile struct { diff --git a/service/internal/httpservers/frontend.go b/service/internal/httpservers/frontend.go index cda9cb5..9860650 100644 --- a/service/internal/httpservers/frontend.go +++ b/service/internal/httpservers/frontend.go @@ -19,6 +19,7 @@ import ( "github.com/OliveTin/OliveTin/internal/auth/otoauth2" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/executor" + "github.com/OliveTin/OliveTin/internal/webhooks" log "github.com/sirupsen/logrus" ) @@ -72,6 +73,10 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) { mux.HandleFunc("/readyz", handleReadyz) + webhookHandler := webhooks.NewWebhookHandler(cfg, ex) + mux.HandleFunc("/webhooks", webhookHandler.HandleWebhook) + mux.HandleFunc("/webhooks/", webhookHandler.HandleWebhook) + webuiServer := NewWebUIServer(cfg) mux.HandleFunc("/theme.css", webuiServer.generateThemeCss) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go new file mode 100644 index 0000000..5e7f26c --- /dev/null +++ b/service/internal/webhooks/auth.go @@ -0,0 +1,129 @@ +package webhooks + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" + + "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +type AuthVerifier struct { + config config.WebhookConfig +} + +func NewAuthVerifier(cfg config.WebhookConfig) *AuthVerifier { + return &AuthVerifier{config: cfg} +} + +func (v *AuthVerifier) Verify(r *http.Request, payload []byte) bool { + switch v.config.AuthType { + case "hmac-sha256": + return v.verifyHMAC256(r, payload) + case "hmac-sha1": + return v.verifyHMAC1(r, payload) + case "bearer": + return v.verifyBearer(r) + case "basic": + return v.verifyBasic(r) + case "none", "": + return true + default: + log.WithFields(log.Fields{ + "authType": v.config.AuthType, + }).Warnf("Unknown auth type, rejecting") + return false + } +} + +func (v *AuthVerifier) verifyHMAC256(r *http.Request, payload []byte) bool { + if v.config.Secret == "" { + log.Warnf("HMAC-SHA256 auth requires secret") + return false + } + + headerName := v.config.AuthHeader + if headerName == "" { + headerName = "X-Webhook-Signature" + } + + signature := r.Header.Get(headerName) + if signature == "" { + log.Debugf("Missing signature header: %s", headerName) + return false + } + + expectedSig := strings.TrimPrefix(signature, "sha256=") + + mac := hmac.New(sha256.New, []byte(v.config.Secret)) + mac.Write(payload) + computedSig := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expectedSig), []byte(computedSig)) +} + +func (v *AuthVerifier) verifyHMAC1(r *http.Request, payload []byte) bool { + if v.config.Secret == "" { + log.Warnf("HMAC-SHA1 auth requires secret") + return false + } + + headerName := v.config.AuthHeader + if headerName == "" { + headerName = "X-Webhook-Signature" + } + + signature := r.Header.Get(headerName) + if signature == "" { + log.Debugf("Missing signature header: %s", headerName) + return false + } + + expectedSig := strings.TrimPrefix(signature, "sha1=") + + mac := hmac.New(sha1.New, []byte(v.config.Secret)) + mac.Write(payload) + computedSig := hex.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expectedSig), []byte(computedSig)) +} + +func (v *AuthVerifier) verifyBearer(r *http.Request) bool { + if v.config.Secret == "" { + log.Warnf("Bearer auth requires secret") + return false + } + + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + log.Debugf("Missing or invalid Bearer token") + return false + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + return token == v.config.Secret +} + +func (v *AuthVerifier) verifyBasic(r *http.Request) bool { + if v.config.Secret == "" { + log.Warnf("Basic auth requires secret") + return false + } + + username, password, ok := r.BasicAuth() + if !ok { + log.Debugf("Missing Basic auth header") + return false + } + + parts := strings.SplitN(v.config.Secret, ":", 2) + if len(parts) == 2 { + return username == parts[0] && password == parts[1] + } + + return password == v.config.Secret +} diff --git a/service/internal/webhooks/github.go b/service/internal/webhooks/github.go new file mode 100644 index 0000000..97b3333 --- /dev/null +++ b/service/internal/webhooks/github.go @@ -0,0 +1,164 @@ +package webhooks + +import ( + "github.com/OliveTin/OliveTin/internal/config" +) + +// ApplyGitHubTemplate applies GitHub-specific template configurations +// This allows users to use simple template names instead of configuring everything manually +func ApplyGitHubTemplate(cfg *config.WebhookConfig, template string) { + switch template { + case "github-push": + applyGitHubPushTemplate(cfg) + case "github-pr", "github-pull-request": + applyGitHubPRTemplate(cfg) + case "github-release": + applyGitHubReleaseTemplate(cfg) + case "github-workflow": + applyGitHubWorkflowTemplate(cfg) + } +} + +func applyGitHubPushTemplate(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } + if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { + cfg.MatchHeaders["X-GitHub-Event"] = "push" + } + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } + if _, exists := cfg.Extract["git_repository"]; !exists { + cfg.Extract["git_repository"] = "$.repository.full_name" + } + if _, exists := cfg.Extract["git_ref"]; !exists { + cfg.Extract["git_ref"] = "$.ref" + } + if _, exists := cfg.Extract["git_commit"]; !exists { + cfg.Extract["git_commit"] = "$.head_commit.id" + } + if _, exists := cfg.Extract["git_branch"]; !exists { + cfg.Extract["git_branch"] = "$.ref" + } + if _, exists := cfg.Extract["git_message"]; !exists { + cfg.Extract["git_message"] = "$.head_commit.message" + } + if _, exists := cfg.Extract["git_author"]; !exists { + cfg.Extract["git_author"] = "$.head_commit.author.name" + } +} + +func applyGitHubPRTemplate(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } + if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { + cfg.MatchHeaders["X-GitHub-Event"] = "pull_request" + } + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } + if _, exists := cfg.Extract["pr_number"]; !exists { + cfg.Extract["pr_number"] = "$.number" + } + if _, exists := cfg.Extract["pr_title"]; !exists { + cfg.Extract["pr_title"] = "$.pull_request.title" + } + if _, exists := cfg.Extract["pr_author"]; !exists { + cfg.Extract["pr_author"] = "$.pull_request.user.login" + } + if _, exists := cfg.Extract["pr_action"]; !exists { + cfg.Extract["pr_action"] = "$.action" + } + if _, exists := cfg.Extract["git_repository"]; !exists { + cfg.Extract["git_repository"] = "$.repository.full_name" + } + if _, exists := cfg.Extract["pr_state"]; !exists { + cfg.Extract["pr_state"] = "$.pull_request.state" + } + if _, exists := cfg.Extract["pr_head_sha"]; !exists { + cfg.Extract["pr_head_sha"] = "$.pull_request.head.sha" + } +} + +func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } + if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { + cfg.MatchHeaders["X-GitHub-Event"] = "release" + } + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } + if _, exists := cfg.Extract["release_action"]; !exists { + cfg.Extract["release_action"] = "$.action" + } + if _, exists := cfg.Extract["release_tag"]; !exists { + cfg.Extract["release_tag"] = "$.release.tag_name" + } + if _, exists := cfg.Extract["release_name"]; !exists { + cfg.Extract["release_name"] = "$.release.name" + } + if _, exists := cfg.Extract["git_repository"]; !exists { + cfg.Extract["git_repository"] = "$.repository.full_name" + } + if _, exists := cfg.Extract["release_author"]; !exists { + cfg.Extract["release_author"] = "$.release.author.login" + } +} + +func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } + if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { + cfg.MatchHeaders["X-GitHub-Event"] = "workflow_run" + } + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } + if _, exists := cfg.Extract["workflow_name"]; !exists { + cfg.Extract["workflow_name"] = "$.workflow_run.name" + } + if _, exists := cfg.Extract["workflow_status"]; !exists { + cfg.Extract["workflow_status"] = "$.workflow_run.status" + } + if _, exists := cfg.Extract["workflow_conclusion"]; !exists { + cfg.Extract["workflow_conclusion"] = "$.workflow_run.conclusion" + } + if _, exists := cfg.Extract["git_repository"]; !exists { + cfg.Extract["git_repository"] = "$.repository.full_name" + } + if _, exists := cfg.Extract["git_commit"]; !exists { + cfg.Extract["git_commit"] = "$.workflow_run.head_sha" + } + if _, exists := cfg.Extract["git_branch"]; !exists { + cfg.Extract["git_branch"] = "$.workflow_run.head_branch" + } +} diff --git a/service/internal/webhooks/handler.go b/service/internal/webhooks/handler.go new file mode 100644 index 0000000..45e6717 --- /dev/null +++ b/service/internal/webhooks/handler.go @@ -0,0 +1,153 @@ +package webhooks + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/OliveTin/OliveTin/internal/auth" + "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/executor" + log "github.com/sirupsen/logrus" +) + +type ActionWebhookConfig struct { + Action *config.Action + Config config.WebhookConfig +} + +type WebhookHandler struct { + cfg *config.Config + executor *executor.Executor +} + +func NewWebhookHandler(cfg *config.Config, ex *executor.Executor) *WebhookHandler { + return &WebhookHandler{ + cfg: cfg, + executor: ex, + } +} + +func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + maxSize := int64(1024 * 1024) + payload, err := io.ReadAll(io.LimitReader(r.Body, maxSize)) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Warnf("Failed to read webhook payload") + http.Error(w, "Failed to read payload", http.StatusBadRequest) + return + } + + var bodyData interface{} + if err := json.Unmarshal(payload, &bodyData); err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Debugf("Webhook payload is not valid JSON") + } + + matchingActions := h.findMatchingActions(r, payload, bodyData) + + if len(matchingActions) == 0 { + log.WithFields(log.Fields{ + "path": r.URL.Path, + "method": r.Method, + }).Debugf("No matching webhook actions found") + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + return + } + + processed := 0 + for _, actionConfig := range matchingActions { + if h.processWebhook(actionConfig, r, payload) { + processed++ + } + } + + log.WithFields(log.Fields{ + "matched": len(matchingActions), + "processed": processed, + }).Infof("Webhook processed") + + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte, bodyData interface{}) []ActionWebhookConfig { + var matches []ActionWebhookConfig + + for _, action := range h.cfg.Actions { + for _, webhookConfig := range action.ExecOnWebhook { + webhookConfigCopy := webhookConfig + + if webhookConfigCopy.Template != "" { + ApplyGitHubTemplate(&webhookConfigCopy, webhookConfigCopy.Template) + } + + matcher := NewWebhookMatcher(webhookConfigCopy, r, payload, bodyData) + + if matcher.Matches() { + matches = append(matches, ActionWebhookConfig{ + Action: action, + Config: webhookConfigCopy, + }) + } + } + } + + return matches +} + +func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *http.Request, payload []byte) bool { + verifier := NewAuthVerifier(actionConfig.Config) + if !verifier.Verify(r, payload) { + log.WithFields(log.Fields{ + "actionTitle": actionConfig.Action.Title, + "authType": actionConfig.Config.AuthType, + }).Warnf("Webhook authentication failed") + return false + } + + var bodyData interface{} + json.Unmarshal(payload, &bodyData) + + matcher := NewWebhookMatcher(actionConfig.Config, r, payload, bodyData) + + args, err := matcher.ExtractArguments() + if err != nil { + log.WithFields(log.Fields{ + "actionTitle": actionConfig.Action.Title, + "error": err, + }).Warnf("Failed to extract webhook arguments") + return false + } + + h.executeAction(actionConfig.Action, args) + return true +} + +func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string) { + binding := h.executor.FindBindingWithNoEntity(action) + if binding == nil { + log.WithFields(log.Fields{ + "actionTitle": action.Title, + }).Warnf("Action binding not found, skipping execution") + return + } + + req := &executor.ExecutionRequest{ + Binding: binding, + Cfg: h.cfg, + Tags: []string{"webhook"}, + Arguments: args, + AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"), + } + + h.executor.ExecRequest(req) +} diff --git a/service/internal/webhooks/jsonpath.go b/service/internal/webhooks/jsonpath.go new file mode 100644 index 0000000..2793df5 --- /dev/null +++ b/service/internal/webhooks/jsonpath.go @@ -0,0 +1,42 @@ +package webhooks + +import ( + "encoding/json" + "fmt" + + "github.com/PaesslerAG/jsonpath" +) + +type JSONMatcher struct { + payload interface{} +} + +func NewJSONMatcher(payload []byte) (*JSONMatcher, error) { + var data interface{} + if err := json.Unmarshal(payload, &data); err != nil { + return nil, err + } + return &JSONMatcher{payload: data}, nil +} + +func (m *JSONMatcher) MatchPath(pathExpr string, expectedValue string) (bool, error) { + value, err := jsonpath.Get(pathExpr, m.payload) + if err != nil { + return false, err + } + + valueStr := fmt.Sprintf("%v", value) + return valueStr == expectedValue, nil +} + +func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) { + value, err := jsonpath.Get(pathExpr, m.payload) + if err != nil { + return "", err + } + return fmt.Sprintf("%v", value), nil +} + +func (m *JSONMatcher) GetPayload() interface{} { + return m.payload +} diff --git a/service/internal/webhooks/matcher.go b/service/internal/webhooks/matcher.go new file mode 100644 index 0000000..3031db3 --- /dev/null +++ b/service/internal/webhooks/matcher.go @@ -0,0 +1,167 @@ +package webhooks + +import ( + "net/http" + "regexp" + "strings" + + "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +type WebhookMatcher struct { + config config.WebhookConfig + req *http.Request + body interface{} + bodyBytes []byte +} + +func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte, body interface{}) *WebhookMatcher { + return &WebhookMatcher{ + config: cfg, + req: r, + body: body, + bodyBytes: bodyBytes, + } +} + +func (m *WebhookMatcher) Matches() bool { + if !m.matchHeaders() { + return false + } + + if !m.matchQuery() { + return false + } + + if !m.matchPath() { + return false + } + + return true +} + +func (m *WebhookMatcher) matchHeaders() bool { + if len(m.config.MatchHeaders) == 0 { + return true + } + + for key, expectedValue := range m.config.MatchHeaders { + actualValue := m.req.Header.Get(key) + if !m.compareValues(actualValue, expectedValue) { + log.WithFields(log.Fields{ + "header": key, + "expected": expectedValue, + "actual": actualValue, + }).Debugf("Header mismatch") + return false + } + } + return true +} + +func (m *WebhookMatcher) matchQuery() bool { + if len(m.config.MatchQuery) == 0 { + return true + } + + query := m.req.URL.Query() + for key, expectedValue := range m.config.MatchQuery { + actualValue := query.Get(key) + if !m.compareValues(actualValue, expectedValue) { + log.WithFields(log.Fields{ + "query": key, + "expected": expectedValue, + "actual": actualValue, + }).Debugf("Query parameter mismatch") + return false + } + } + return true +} + +func (m *WebhookMatcher) matchPath() bool { + if m.config.MatchPath == "" { + return true + } + + parts := strings.SplitN(m.config.MatchPath, "=", 2) + jsonPath := parts[0] + expectedValue := "" + if len(parts) == 2 { + expectedValue = parts[1] + } + + matcher, err := NewJSONMatcher(m.bodyBytes) + if err != nil { + log.WithFields(log.Fields{ + "error": err, + }).Debugf("Failed to create JSON matcher") + return false + } + + if expectedValue == "" { + _, err := matcher.ExtractValue(jsonPath) + return err == nil + } + + matches, err := matcher.MatchPath(jsonPath, expectedValue) + if err != nil { + log.WithFields(log.Fields{ + "jsonPath": jsonPath, + "error": err, + }).Debugf("Failed to match JSONPath") + return false + } + return matches +} + +func (m *WebhookMatcher) compareValues(actual, expected string) bool { + if strings.HasPrefix(expected, "regex:") { + pattern := strings.TrimPrefix(expected, "regex:") + matched, err := regexp.MatchString(pattern, actual) + if err != nil { + log.WithFields(log.Fields{ + "pattern": pattern, + "error": err, + }).Warnf("Invalid regex pattern") + return false + } + return matched + } + return actual == expected +} + +func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) { + args := make(map[string]string) + + matcher, err := NewJSONMatcher(m.bodyBytes) + if err != nil { + return nil, err + } + + for argName, jsonPath := range m.config.Extract { + value, err := matcher.ExtractValue(jsonPath) + if err != nil { + log.WithFields(log.Fields{ + "argName": argName, + "jsonPath": jsonPath, + "error": err, + }).Debugf("Failed to extract value") + continue + } + args[argName] = value + } + + args["webhook_method"] = m.req.Method + args["webhook_path"] = m.req.URL.Path + args["webhook_query"] = m.req.URL.RawQuery + + for key, values := range m.req.Header { + if len(values) > 0 { + args["webhook_header_"+strings.ToLower(key)] = values[0] + } + } + + return args, nil +} From 11278ff6c275bd82579b02ef4a79d41d1e89e645 Mon Sep 17 00:00:00 2001 From: James Read Date: Tue, 6 Jan 2026 22:09:17 +0000 Subject: [PATCH 2/5] fix: Use constant-time comparison for Basic auth verification. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- service/internal/webhooks/auth.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go index 5e7f26c..31daf80 100644 --- a/service/internal/webhooks/auth.go +++ b/service/internal/webhooks/auth.go @@ -120,10 +120,21 @@ func (v *AuthVerifier) verifyBasic(r *http.Request) bool { return false } +import ( + "crypto/subtle" + // ... existing imports +) + +func (v *AuthVerifier) verifyBasic(r *http.Request) bool { + // ... existing checks ... + parts := strings.SplitN(v.config.Secret, ":", 2) if len(parts) == 2 { - return username == parts[0] && password == parts[1] + usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(parts[0])) + passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(parts[1])) + return usernameMatch == 1 && passwordMatch == 1 } - return password == v.config.Secret + return subtle.ConstantTimeCompare([]byte(password), []byte(v.config.Secret)) == 1 +} } From 0b072db36df3460b5878800b05c81daa22c0d0cc Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 22:11:41 +0000 Subject: [PATCH 3/5] fix: Constant time comparison for webhook authentication --- service/internal/webhooks/auth.go | 5 ++++- service/internal/webhooks/matcher.go | 15 +++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go index 5e7f26c..c3d6a5a 100644 --- a/service/internal/webhooks/auth.go +++ b/service/internal/webhooks/auth.go @@ -2,6 +2,7 @@ package webhooks import ( "crypto/hmac" + "crypto/subtle" "crypto/sha1" "crypto/sha256" "encoding/hex" @@ -105,7 +106,9 @@ func (v *AuthVerifier) verifyBearer(r *http.Request) bool { } token := strings.TrimPrefix(authHeader, "Bearer ") - return token == v.config.Secret + tokenBytes := []byte(token) + secretBytes := []byte(v.config.Secret) + return len(tokenBytes) == len(secretBytes) && subtle.ConstantTimeCompare(tokenBytes, secretBytes) == 1 } func (v *AuthVerifier) verifyBasic(r *http.Request) bool { diff --git a/service/internal/webhooks/matcher.go b/service/internal/webhooks/matcher.go index 3031db3..8b43165 100644 --- a/service/internal/webhooks/matcher.go +++ b/service/internal/webhooks/matcher.go @@ -12,7 +12,6 @@ import ( type WebhookMatcher struct { config config.WebhookConfig req *http.Request - body interface{} bodyBytes []byte } @@ -50,9 +49,9 @@ func (m *WebhookMatcher) matchHeaders() bool { actualValue := m.req.Header.Get(key) if !m.compareValues(actualValue, expectedValue) { log.WithFields(log.Fields{ - "header": key, - "expected": expectedValue, - "actual": actualValue, + "header": key, + "expected": expectedValue, + "actual": actualValue, }).Debugf("Header mismatch") return false } @@ -70,9 +69,9 @@ func (m *WebhookMatcher) matchQuery() bool { actualValue := query.Get(key) if !m.compareValues(actualValue, expectedValue) { log.WithFields(log.Fields{ - "query": key, - "expected": expectedValue, - "actual": actualValue, + "query": key, + "expected": expectedValue, + "actual": actualValue, }).Debugf("Query parameter mismatch") return false } @@ -144,7 +143,7 @@ func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) { value, err := matcher.ExtractValue(jsonPath) if err != nil { log.WithFields(log.Fields{ - "argName": argName, + "argName": argName, "jsonPath": jsonPath, "error": err, }).Debugf("Failed to extract value") From 0368fb10dbb74c6b8f51e45dccd0dfa1117cd2b5 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 22:15:37 +0000 Subject: [PATCH 4/5] fix: wonky merge conflict, and missing body parameter from matcher --- service/internal/webhooks/auth.go | 11 +---------- service/internal/webhooks/matcher.go | 1 - 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go index b81ec31..7c56304 100644 --- a/service/internal/webhooks/auth.go +++ b/service/internal/webhooks/auth.go @@ -2,9 +2,9 @@ package webhooks import ( "crypto/hmac" - "crypto/subtle" "crypto/sha1" "crypto/sha256" + "crypto/subtle" "encoding/hex" "net/http" "strings" @@ -123,14 +123,6 @@ func (v *AuthVerifier) verifyBasic(r *http.Request) bool { return false } -import ( - "crypto/subtle" - // ... existing imports -) - -func (v *AuthVerifier) verifyBasic(r *http.Request) bool { - // ... existing checks ... - parts := strings.SplitN(v.config.Secret, ":", 2) if len(parts) == 2 { usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(parts[0])) @@ -140,4 +132,3 @@ func (v *AuthVerifier) verifyBasic(r *http.Request) bool { return subtle.ConstantTimeCompare([]byte(password), []byte(v.config.Secret)) == 1 } -} diff --git a/service/internal/webhooks/matcher.go b/service/internal/webhooks/matcher.go index 8b43165..dbea358 100644 --- a/service/internal/webhooks/matcher.go +++ b/service/internal/webhooks/matcher.go @@ -19,7 +19,6 @@ func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []by return &WebhookMatcher{ config: cfg, req: r, - body: body, bodyBytes: bodyBytes, } } From c714dc0c62808a9f824ac54c2085ff44724e52e8 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 6 Jan 2026 23:23:47 +0000 Subject: [PATCH 5/5] chore: Webhooks fixes --- service/internal/webhooks/auth.go | 73 +++++--- service/internal/webhooks/github.go | 230 ++++++++++---------------- service/internal/webhooks/handler.go | 99 ++++++----- service/internal/webhooks/jsonpath.go | 17 +- service/internal/webhooks/matcher.go | 44 +++-- 5 files changed, 244 insertions(+), 219 deletions(-) diff --git a/service/internal/webhooks/auth.go b/service/internal/webhooks/auth.go index 7c56304..b03ce99 100644 --- a/service/internal/webhooks/auth.go +++ b/service/internal/webhooks/auth.go @@ -22,23 +22,47 @@ func NewAuthVerifier(cfg config.WebhookConfig) *AuthVerifier { } func (v *AuthVerifier) Verify(r *http.Request, payload []byte) bool { - switch v.config.AuthType { - case "hmac-sha256": - return v.verifyHMAC256(r, payload) - case "hmac-sha1": - return v.verifyHMAC1(r, payload) - case "bearer": - return v.verifyBearer(r) - case "basic": - return v.verifyBasic(r) - case "none", "": - return true - default: - log.WithFields(log.Fields{ - "authType": v.config.AuthType, - }).Warnf("Unknown auth type, rejecting") - return false + verifier := v.getVerifier() + if verifier == nil { + return v.handleUnknownAuthType() } + return verifier(r, payload) +} + +type authVerifierFunc func(*http.Request, []byte) bool + +func (v *AuthVerifier) getVerifier() authVerifierFunc { + if v.config.AuthType == "" || v.config.AuthType == "none" { + return func(_ *http.Request, _ []byte) bool { + return true + } + } + + verifierMap := v.buildVerifierMap() + if verifier, ok := verifierMap[v.config.AuthType]; ok { + return verifier + } + return nil +} + +func (v *AuthVerifier) buildVerifierMap() map[string]authVerifierFunc { + return map[string]authVerifierFunc{ + "hmac-sha256": v.verifyHMAC256, + "hmac-sha1": v.verifyHMAC1, + "bearer": func(r *http.Request, _ []byte) bool { + return v.verifyBearer(r) + }, + "basic": func(r *http.Request, _ []byte) bool { + return v.verifyBasic(r) + }, + } +} + +func (v *AuthVerifier) handleUnknownAuthType() bool { + log.WithFields(log.Fields{ + "authType": v.config.AuthType, + }).Warnf("Unknown auth type, rejecting") + return false } func (v *AuthVerifier) verifyHMAC256(r *http.Request, payload []byte) bool { @@ -123,12 +147,23 @@ func (v *AuthVerifier) verifyBasic(r *http.Request) bool { return false } + return v.verifyBasicCredentials(username, password) +} + +func (v *AuthVerifier) verifyBasicCredentials(username, password string) bool { parts := strings.SplitN(v.config.Secret, ":", 2) if len(parts) == 2 { - usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(parts[0])) - passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(parts[1])) - return usernameMatch == 1 && passwordMatch == 1 + return v.verifyBasicWithUsername(username, password, parts[0], parts[1]) } + return v.verifyBasicPasswordOnly(password) +} +func (v *AuthVerifier) verifyBasicWithUsername(username, password, expectedUsername, expectedPassword string) bool { + usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) + passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) + return usernameMatch == 1 && passwordMatch == 1 +} + +func (v *AuthVerifier) verifyBasicPasswordOnly(password string) bool { return subtle.ConstantTimeCompare([]byte(password), []byte(v.config.Secret)) == 1 } diff --git a/service/internal/webhooks/github.go b/service/internal/webhooks/github.go index 97b3333..431e6ba 100644 --- a/service/internal/webhooks/github.go +++ b/service/internal/webhooks/github.go @@ -7,158 +7,106 @@ import ( // ApplyGitHubTemplate applies GitHub-specific template configurations // This allows users to use simple template names instead of configuring everything manually func ApplyGitHubTemplate(cfg *config.WebhookConfig, template string) { - switch template { - case "github-push": - applyGitHubPushTemplate(cfg) - case "github-pr", "github-pull-request": - applyGitHubPRTemplate(cfg) - case "github-release": - applyGitHubReleaseTemplate(cfg) - case "github-workflow": - applyGitHubWorkflowTemplate(cfg) + applier := getTemplateApplier(template) + if applier != nil { + applier(cfg) + } +} + +type templateApplier func(*config.WebhookConfig) + +func getTemplateApplier(template string) templateApplier { + templateMap := map[string]templateApplier{ + "github-push": applyGitHubPushTemplate, + "github-pr": applyGitHubPRTemplate, + "github-pull-request": applyGitHubPRTemplate, + "github-release": applyGitHubReleaseTemplate, + "github-workflow": applyGitHubWorkflowTemplate, + } + return templateMap[template] +} + +func setDefaultAuth(cfg *config.WebhookConfig) { + if cfg.AuthHeader == "" { + cfg.AuthHeader = "X-Hub-Signature-256" + } + if cfg.AuthType == "" { + cfg.AuthType = "hmac-sha256" + } +} + +func ensureMatchHeaders(cfg *config.WebhookConfig) { + if len(cfg.MatchHeaders) == 0 { + cfg.MatchHeaders = make(map[string]string) + } +} + +func ensureExtract(cfg *config.WebhookConfig) { + if len(cfg.Extract) == 0 { + cfg.Extract = make(map[string]string) + } +} + +func setExtractIfMissing(cfg *config.WebhookConfig, key, value string) { + if _, exists := cfg.Extract[key]; !exists { + cfg.Extract[key] = value + } +} + +func setMatchHeaderIfMissing(cfg *config.WebhookConfig, key, value string) { + if _, exists := cfg.MatchHeaders[key]; !exists { + cfg.MatchHeaders[key] = value } } func applyGitHubPushTemplate(cfg *config.WebhookConfig) { - if cfg.AuthHeader == "" { - cfg.AuthHeader = "X-Hub-Signature-256" - } - if cfg.AuthType == "" { - cfg.AuthType = "hmac-sha256" - } - if len(cfg.MatchHeaders) == 0 { - cfg.MatchHeaders = make(map[string]string) - } - if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { - cfg.MatchHeaders["X-GitHub-Event"] = "push" - } - if len(cfg.Extract) == 0 { - cfg.Extract = make(map[string]string) - } - if _, exists := cfg.Extract["git_repository"]; !exists { - cfg.Extract["git_repository"] = "$.repository.full_name" - } - if _, exists := cfg.Extract["git_ref"]; !exists { - cfg.Extract["git_ref"] = "$.ref" - } - if _, exists := cfg.Extract["git_commit"]; !exists { - cfg.Extract["git_commit"] = "$.head_commit.id" - } - if _, exists := cfg.Extract["git_branch"]; !exists { - cfg.Extract["git_branch"] = "$.ref" - } - if _, exists := cfg.Extract["git_message"]; !exists { - cfg.Extract["git_message"] = "$.head_commit.message" - } - if _, exists := cfg.Extract["git_author"]; !exists { - cfg.Extract["git_author"] = "$.head_commit.author.name" - } + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "push") + ensureExtract(cfg) + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "git_ref", "$.ref") + setExtractIfMissing(cfg, "git_commit", "$.head_commit.id") + setExtractIfMissing(cfg, "git_branch", "$.ref") + setExtractIfMissing(cfg, "git_message", "$.head_commit.message") + setExtractIfMissing(cfg, "git_author", "$.head_commit.author.name") } func applyGitHubPRTemplate(cfg *config.WebhookConfig) { - if cfg.AuthHeader == "" { - cfg.AuthHeader = "X-Hub-Signature-256" - } - if cfg.AuthType == "" { - cfg.AuthType = "hmac-sha256" - } - if len(cfg.MatchHeaders) == 0 { - cfg.MatchHeaders = make(map[string]string) - } - if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { - cfg.MatchHeaders["X-GitHub-Event"] = "pull_request" - } - if len(cfg.Extract) == 0 { - cfg.Extract = make(map[string]string) - } - if _, exists := cfg.Extract["pr_number"]; !exists { - cfg.Extract["pr_number"] = "$.number" - } - if _, exists := cfg.Extract["pr_title"]; !exists { - cfg.Extract["pr_title"] = "$.pull_request.title" - } - if _, exists := cfg.Extract["pr_author"]; !exists { - cfg.Extract["pr_author"] = "$.pull_request.user.login" - } - if _, exists := cfg.Extract["pr_action"]; !exists { - cfg.Extract["pr_action"] = "$.action" - } - if _, exists := cfg.Extract["git_repository"]; !exists { - cfg.Extract["git_repository"] = "$.repository.full_name" - } - if _, exists := cfg.Extract["pr_state"]; !exists { - cfg.Extract["pr_state"] = "$.pull_request.state" - } - if _, exists := cfg.Extract["pr_head_sha"]; !exists { - cfg.Extract["pr_head_sha"] = "$.pull_request.head.sha" - } + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "pull_request") + ensureExtract(cfg) + setExtractIfMissing(cfg, "pr_number", "$.number") + setExtractIfMissing(cfg, "pr_title", "$.pull_request.title") + setExtractIfMissing(cfg, "pr_author", "$.pull_request.user.login") + setExtractIfMissing(cfg, "pr_action", "$.action") + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "pr_state", "$.pull_request.state") + setExtractIfMissing(cfg, "pr_head_sha", "$.pull_request.head.sha") } func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) { - if cfg.AuthHeader == "" { - cfg.AuthHeader = "X-Hub-Signature-256" - } - if cfg.AuthType == "" { - cfg.AuthType = "hmac-sha256" - } - if len(cfg.MatchHeaders) == 0 { - cfg.MatchHeaders = make(map[string]string) - } - if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { - cfg.MatchHeaders["X-GitHub-Event"] = "release" - } - if len(cfg.Extract) == 0 { - cfg.Extract = make(map[string]string) - } - if _, exists := cfg.Extract["release_action"]; !exists { - cfg.Extract["release_action"] = "$.action" - } - if _, exists := cfg.Extract["release_tag"]; !exists { - cfg.Extract["release_tag"] = "$.release.tag_name" - } - if _, exists := cfg.Extract["release_name"]; !exists { - cfg.Extract["release_name"] = "$.release.name" - } - if _, exists := cfg.Extract["git_repository"]; !exists { - cfg.Extract["git_repository"] = "$.repository.full_name" - } - if _, exists := cfg.Extract["release_author"]; !exists { - cfg.Extract["release_author"] = "$.release.author.login" - } + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "release") + ensureExtract(cfg) + setExtractIfMissing(cfg, "release_action", "$.action") + setExtractIfMissing(cfg, "release_tag", "$.release.tag_name") + setExtractIfMissing(cfg, "release_name", "$.release.name") + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "release_author", "$.release.author.login") } func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) { - if cfg.AuthHeader == "" { - cfg.AuthHeader = "X-Hub-Signature-256" - } - if cfg.AuthType == "" { - cfg.AuthType = "hmac-sha256" - } - if len(cfg.MatchHeaders) == 0 { - cfg.MatchHeaders = make(map[string]string) - } - if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists { - cfg.MatchHeaders["X-GitHub-Event"] = "workflow_run" - } - if len(cfg.Extract) == 0 { - cfg.Extract = make(map[string]string) - } - if _, exists := cfg.Extract["workflow_name"]; !exists { - cfg.Extract["workflow_name"] = "$.workflow_run.name" - } - if _, exists := cfg.Extract["workflow_status"]; !exists { - cfg.Extract["workflow_status"] = "$.workflow_run.status" - } - if _, exists := cfg.Extract["workflow_conclusion"]; !exists { - cfg.Extract["workflow_conclusion"] = "$.workflow_run.conclusion" - } - if _, exists := cfg.Extract["git_repository"]; !exists { - cfg.Extract["git_repository"] = "$.repository.full_name" - } - if _, exists := cfg.Extract["git_commit"]; !exists { - cfg.Extract["git_commit"] = "$.workflow_run.head_sha" - } - if _, exists := cfg.Extract["git_branch"]; !exists { - cfg.Extract["git_branch"] = "$.workflow_run.head_branch" - } + setDefaultAuth(cfg) + ensureMatchHeaders(cfg) + setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "workflow_run") + ensureExtract(cfg) + setExtractIfMissing(cfg, "workflow_name", "$.workflow_run.name") + setExtractIfMissing(cfg, "workflow_status", "$.workflow_run.status") + setExtractIfMissing(cfg, "workflow_conclusion", "$.workflow_run.conclusion") + setExtractIfMissing(cfg, "git_repository", "$.repository.full_name") + setExtractIfMissing(cfg, "git_commit", "$.workflow_run.head_sha") + setExtractIfMissing(cfg, "git_branch", "$.workflow_run.head_branch") } diff --git a/service/internal/webhooks/handler.go b/service/internal/webhooks/handler.go index 45e6717..a530690 100644 --- a/service/internal/webhooks/handler.go +++ b/service/internal/webhooks/handler.go @@ -1,7 +1,6 @@ package webhooks import ( - "encoding/json" "io" "net/http" @@ -34,70 +33,83 @@ func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { return } + payload, err := h.readPayload(r) + if err != nil { + http.Error(w, "Failed to read payload", http.StatusBadRequest) + return + } + + matchingActions := h.findMatchingActions(r, payload) + if len(matchingActions) == 0 { + h.writeOKResponse(w, "no matching webhook actions") + return + } + + processed := h.processMatchingActions(matchingActions, r, payload) + log.WithFields(log.Fields{ + "matched": len(matchingActions), + "processed": processed, + }).Infof("Webhook processed") + + h.writeOKResponse(w, "webhook actions") +} + +func (h *WebhookHandler) readPayload(r *http.Request) ([]byte, error) { maxSize := int64(1024 * 1024) payload, err := io.ReadAll(io.LimitReader(r.Body, maxSize)) if err != nil { log.WithFields(log.Fields{ "error": err, }).Warnf("Failed to read webhook payload") - http.Error(w, "Failed to read payload", http.StatusBadRequest) - return + return nil, err } - var bodyData interface{} - if err := json.Unmarshal(payload, &bodyData); err != nil { - log.WithFields(log.Fields{ - "error": err, - }).Debugf("Webhook payload is not valid JSON") - } - - matchingActions := h.findMatchingActions(r, payload, bodyData) - - if len(matchingActions) == 0 { - log.WithFields(log.Fields{ - "path": r.URL.Path, - "method": r.Method, - }).Debugf("No matching webhook actions found") - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) - return + return payload, nil +} + +func (h *WebhookHandler) writeOKResponse(w http.ResponseWriter, context string) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("OK")); err != nil { + log.WithError(err).Warnf("Failed to write response for %s", context) } +} +func (h *WebhookHandler) processMatchingActions(matchingActions []ActionWebhookConfig, r *http.Request, payload []byte) int { processed := 0 for _, actionConfig := range matchingActions { if h.processWebhook(actionConfig, r, payload) { processed++ } } - - log.WithFields(log.Fields{ - "matched": len(matchingActions), - "processed": processed, - }).Infof("Webhook processed") - - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + return processed } -func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte, bodyData interface{}) []ActionWebhookConfig { +func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte) []ActionWebhookConfig { var matches []ActionWebhookConfig for _, action := range h.cfg.Actions { - for _, webhookConfig := range action.ExecOnWebhook { - webhookConfigCopy := webhookConfig + matches = append(matches, h.findMatchingWebhooksForAction(action, r, payload)...) + } - if webhookConfigCopy.Template != "" { - ApplyGitHubTemplate(&webhookConfigCopy, webhookConfigCopy.Template) - } + return matches +} - matcher := NewWebhookMatcher(webhookConfigCopy, r, payload, bodyData) +func (h *WebhookHandler) findMatchingWebhooksForAction(action *config.Action, r *http.Request, payload []byte) []ActionWebhookConfig { + var matches []ActionWebhookConfig - if matcher.Matches() { - matches = append(matches, ActionWebhookConfig{ - Action: action, - Config: webhookConfigCopy, - }) - } + for _, webhookConfig := range action.ExecOnWebhook { + webhookConfigCopy := webhookConfig + + if webhookConfigCopy.Template != "" { + ApplyGitHubTemplate(&webhookConfigCopy, webhookConfigCopy.Template) + } + + matcher := NewWebhookMatcher(webhookConfigCopy, r, payload) + if matcher.Matches() { + matches = append(matches, ActionWebhookConfig{ + Action: action, + Config: webhookConfigCopy, + }) } } @@ -114,10 +126,7 @@ func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *htt return false } - var bodyData interface{} - json.Unmarshal(payload, &bodyData) - - matcher := NewWebhookMatcher(actionConfig.Config, r, payload, bodyData) + matcher := NewWebhookMatcher(actionConfig.Config, r, payload) args, err := matcher.ExtractArguments() if err != nil { diff --git a/service/internal/webhooks/jsonpath.go b/service/internal/webhooks/jsonpath.go index 2793df5..7fc5309 100644 --- a/service/internal/webhooks/jsonpath.go +++ b/service/internal/webhooks/jsonpath.go @@ -25,7 +25,13 @@ func (m *JSONMatcher) MatchPath(pathExpr string, expectedValue string) (bool, er return false, err } - valueStr := fmt.Sprintf("%v", value) + // Marshal to JSON for consistent string representation + jsonBytes, err := json.Marshal(value) + if err != nil { + return false, fmt.Errorf("failed to marshal extracted value: %w", err) + } + + valueStr := string(jsonBytes) return valueStr == expectedValue, nil } @@ -34,7 +40,14 @@ func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) { if err != nil { return "", err } - return fmt.Sprintf("%v", value), nil + + // Marshal to JSON for consistent string representation + jsonBytes, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("failed to marshal extracted value: %w", err) + } + + return string(jsonBytes), nil } func (m *JSONMatcher) GetPayload() interface{} { diff --git a/service/internal/webhooks/matcher.go b/service/internal/webhooks/matcher.go index dbea358..3d30d5e 100644 --- a/service/internal/webhooks/matcher.go +++ b/service/internal/webhooks/matcher.go @@ -15,7 +15,7 @@ type WebhookMatcher struct { bodyBytes []byte } -func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte, body interface{}) *WebhookMatcher { +func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte) *WebhookMatcher { return &WebhookMatcher{ config: cfg, req: r, @@ -83,13 +83,7 @@ func (m *WebhookMatcher) matchPath() bool { return true } - parts := strings.SplitN(m.config.MatchPath, "=", 2) - jsonPath := parts[0] - expectedValue := "" - if len(parts) == 2 { - expectedValue = parts[1] - } - + jsonPath, expectedValue := m.parseMatchPath() matcher, err := NewJSONMatcher(m.bodyBytes) if err != nil { log.WithFields(log.Fields{ @@ -98,6 +92,20 @@ func (m *WebhookMatcher) matchPath() bool { return false } + return m.matchPathValue(matcher, jsonPath, expectedValue) +} + +func (m *WebhookMatcher) parseMatchPath() (string, string) { + parts := strings.SplitN(m.config.MatchPath, "=", 2) + jsonPath := parts[0] + expectedValue := "" + if len(parts) == 2 { + expectedValue = parts[1] + } + return jsonPath, expectedValue +} + +func (m *WebhookMatcher) matchPathValue(matcher *JSONMatcher, jsonPath, expectedValue string) bool { if expectedValue == "" { _, err := matcher.ExtractValue(jsonPath) return err == nil @@ -131,13 +139,21 @@ func (m *WebhookMatcher) compareValues(actual, expected string) bool { } func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) { - args := make(map[string]string) - matcher, err := NewJSONMatcher(m.bodyBytes) if err != nil { return nil, err } + args := m.extractJSONPathValues(matcher) + m.addWebhookMetadata(args) + m.addWebhookHeaders(args) + + return args, nil +} + +func (m *WebhookMatcher) extractJSONPathValues(matcher *JSONMatcher) map[string]string { + args := make(map[string]string) + for argName, jsonPath := range m.config.Extract { value, err := matcher.ExtractValue(jsonPath) if err != nil { @@ -151,15 +167,19 @@ func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) { args[argName] = value } + return args +} + +func (m *WebhookMatcher) addWebhookMetadata(args map[string]string) { args["webhook_method"] = m.req.Method args["webhook_path"] = m.req.URL.Path args["webhook_query"] = m.req.URL.RawQuery +} +func (m *WebhookMatcher) addWebhookHeaders(args map[string]string) { for key, values := range m.req.Header { if len(values) > 0 { args["webhook_header_"+strings.ToLower(key)] = values[0] } } - - return args, nil }