Sfoglia il codice sorgente

feat: webhooks support

jamesread 5 mesi fa
parent
commit
f22b3953b1

+ 2 - 0
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

+ 5 - 0
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=

+ 14 - 0
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 {

+ 5 - 0
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)

+ 129 - 0
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
+}

+ 164 - 0
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"
+	}
+}

+ 153 - 0
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)
+}

+ 42 - 0
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
+}

+ 167 - 0
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
+}