James Read 5 месяцев назад
Родитель
Сommit
15c4546150

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

+ 169 - 0
service/internal/webhooks/auth.go

@@ -0,0 +1,169 @@
+package webhooks
+
+import (
+	"crypto/hmac"
+	"crypto/sha1"
+	"crypto/sha256"
+	"crypto/subtle"
+	"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 {
+	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 {
+	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 ")
+	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 {
+	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
+	}
+
+	return v.verifyBasicCredentials(username, password)
+}
+
+func (v *AuthVerifier) verifyBasicCredentials(username, password string) bool {
+	parts := strings.SplitN(v.config.Secret, ":", 2)
+	if len(parts) == 2 {
+		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
+}

+ 112 - 0
service/internal/webhooks/github.go

@@ -0,0 +1,112 @@
+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) {
+	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) {
+	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) {
+	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) {
+	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) {
+	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")
+}

+ 162 - 0
service/internal/webhooks/handler.go

@@ -0,0 +1,162 @@
+package webhooks
+
+import (
+	"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
+	}
+
+	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")
+		return nil, err
+	}
+
+	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++
+		}
+	}
+	return processed
+}
+
+func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte) []ActionWebhookConfig {
+	var matches []ActionWebhookConfig
+
+	for _, action := range h.cfg.Actions {
+		matches = append(matches, h.findMatchingWebhooksForAction(action, r, payload)...)
+	}
+
+	return matches
+}
+
+func (h *WebhookHandler) findMatchingWebhooksForAction(action *config.Action, r *http.Request, payload []byte) []ActionWebhookConfig {
+	var matches []ActionWebhookConfig
+
+	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,
+			})
+		}
+	}
+
+	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
+	}
+
+	matcher := NewWebhookMatcher(actionConfig.Config, r, payload)
+
+	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)
+}

+ 55 - 0
service/internal/webhooks/jsonpath.go

@@ -0,0 +1,55 @@
+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
+	}
+
+	// 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
+}
+
+func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) {
+	value, err := jsonpath.Get(pathExpr, m.payload)
+	if err != nil {
+		return "", err
+	}
+
+	// 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{} {
+	return m.payload
+}

+ 185 - 0
service/internal/webhooks/matcher.go

@@ -0,0 +1,185 @@
+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
+	bodyBytes []byte
+}
+
+func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte) *WebhookMatcher {
+	return &WebhookMatcher{
+		config:    cfg,
+		req:       r,
+		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
+	}
+
+	jsonPath, expectedValue := m.parseMatchPath()
+	matcher, err := NewJSONMatcher(m.bodyBytes)
+	if err != nil {
+		log.WithFields(log.Fields{
+			"error": err,
+		}).Debugf("Failed to create JSON matcher")
+		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
+	}
+
+	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) {
+	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 {
+			log.WithFields(log.Fields{
+				"argName":  argName,
+				"jsonPath": jsonPath,
+				"error":    err,
+			}).Debugf("Failed to extract value")
+			continue
+		}
+		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]
+		}
+	}
+}