github.com/argoproj/argo-cd/v3@v3.2.1/util/webhook/webhook.go (about) 1 package webhook 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "html" 8 "net/http" 9 "net/url" 10 "regexp" 11 "strings" 12 "sync" 13 "time" 14 15 bb "github.com/ktrysmt/go-bitbucket" 16 "k8s.io/apimachinery/pkg/labels" 17 18 alpha1 "github.com/argoproj/argo-cd/v3/pkg/client/listers/application/v1alpha1" 19 20 "github.com/Masterminds/semver/v3" 21 "github.com/go-playground/webhooks/v6/azuredevops" 22 "github.com/go-playground/webhooks/v6/bitbucket" 23 bitbucketserver "github.com/go-playground/webhooks/v6/bitbucket-server" 24 "github.com/go-playground/webhooks/v6/github" 25 "github.com/go-playground/webhooks/v6/gitlab" 26 "github.com/go-playground/webhooks/v6/gogs" 27 gogsclient "github.com/gogits/go-gogs-client" 28 log "github.com/sirupsen/logrus" 29 30 "github.com/argoproj/argo-cd/v3/common" 31 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" 32 appclientset "github.com/argoproj/argo-cd/v3/pkg/client/clientset/versioned" 33 "github.com/argoproj/argo-cd/v3/reposerver/cache" 34 servercache "github.com/argoproj/argo-cd/v3/server/cache" 35 "github.com/argoproj/argo-cd/v3/util/app/path" 36 "github.com/argoproj/argo-cd/v3/util/argo" 37 "github.com/argoproj/argo-cd/v3/util/db" 38 "github.com/argoproj/argo-cd/v3/util/git" 39 "github.com/argoproj/argo-cd/v3/util/glob" 40 "github.com/argoproj/argo-cd/v3/util/guard" 41 "github.com/argoproj/argo-cd/v3/util/settings" 42 ) 43 44 type settingsSource interface { 45 GetAppInstanceLabelKey() (string, error) 46 GetTrackingMethod() (string, error) 47 GetInstallationID() (string, error) 48 } 49 50 // https://www.rfc-editor.org/rfc/rfc3986#section-3.2.1 51 // https://github.com/shadow-maint/shadow/blob/master/libmisc/chkname.c#L36 52 const usernameRegex = `[\w\.][\w\.-]{0,30}[\w\.\$-]?` 53 54 const payloadQueueSize = 50000 55 56 const panicMsgServer = "panic while processing api-server webhook event" 57 58 var _ settingsSource = &settings.SettingsManager{} 59 60 type ArgoCDWebhookHandler struct { 61 sync.WaitGroup // for testing 62 repoCache *cache.Cache 63 serverCache *servercache.Cache 64 db db.ArgoDB 65 ns string 66 appNs []string 67 appClientset appclientset.Interface 68 appsLister alpha1.ApplicationLister 69 github *github.Webhook 70 gitlab *gitlab.Webhook 71 bitbucket *bitbucket.Webhook 72 bitbucketserver *bitbucketserver.Webhook 73 azuredevops *azuredevops.Webhook 74 gogs *gogs.Webhook 75 settings *settings.ArgoCDSettings 76 settingsSrc settingsSource 77 queue chan any 78 maxWebhookPayloadSizeB int64 79 } 80 81 func NewHandler(namespace string, applicationNamespaces []string, webhookParallelism int, appClientset appclientset.Interface, appsLister alpha1.ApplicationLister, set *settings.ArgoCDSettings, settingsSrc settingsSource, repoCache *cache.Cache, serverCache *servercache.Cache, argoDB db.ArgoDB, maxWebhookPayloadSizeB int64) *ArgoCDWebhookHandler { 82 githubWebhook, err := github.New(github.Options.Secret(set.GetWebhookGitHubSecret())) 83 if err != nil { 84 log.Warnf("Unable to init the GitHub webhook") 85 } 86 gitlabWebhook, err := gitlab.New(gitlab.Options.Secret(set.GetWebhookGitLabSecret())) 87 if err != nil { 88 log.Warnf("Unable to init the GitLab webhook") 89 } 90 bitbucketWebhook, err := bitbucket.New(bitbucket.Options.UUID(set.GetWebhookBitbucketUUID())) 91 if err != nil { 92 log.Warnf("Unable to init the Bitbucket webhook") 93 } 94 bitbucketserverWebhook, err := bitbucketserver.New(bitbucketserver.Options.Secret(set.GetWebhookBitbucketServerSecret())) 95 if err != nil { 96 log.Warnf("Unable to init the Bitbucket Server webhook") 97 } 98 gogsWebhook, err := gogs.New(gogs.Options.Secret(set.GetWebhookGogsSecret())) 99 if err != nil { 100 log.Warnf("Unable to init the Gogs webhook") 101 } 102 azuredevopsWebhook, err := azuredevops.New(azuredevops.Options.BasicAuth(set.GetWebhookAzureDevOpsUsername(), set.GetWebhookAzureDevOpsPassword())) 103 if err != nil { 104 log.Warnf("Unable to init the Azure DevOps webhook") 105 } 106 107 acdWebhook := ArgoCDWebhookHandler{ 108 ns: namespace, 109 appNs: applicationNamespaces, 110 appClientset: appClientset, 111 github: githubWebhook, 112 gitlab: gitlabWebhook, 113 bitbucket: bitbucketWebhook, 114 bitbucketserver: bitbucketserverWebhook, 115 azuredevops: azuredevopsWebhook, 116 gogs: gogsWebhook, 117 settingsSrc: settingsSrc, 118 repoCache: repoCache, 119 serverCache: serverCache, 120 settings: set, 121 db: argoDB, 122 queue: make(chan any, payloadQueueSize), 123 maxWebhookPayloadSizeB: maxWebhookPayloadSizeB, 124 appsLister: appsLister, 125 } 126 127 acdWebhook.startWorkerPool(webhookParallelism) 128 129 return &acdWebhook 130 } 131 132 func (a *ArgoCDWebhookHandler) startWorkerPool(webhookParallelism int) { 133 compLog := log.WithField("component", "api-server-webhook") 134 for i := 0; i < webhookParallelism; i++ { 135 a.Add(1) 136 go func() { 137 defer a.Done() 138 for { 139 payload, ok := <-a.queue 140 if !ok { 141 return 142 } 143 guard.RecoverAndLog(func() { a.HandleEvent(payload) }, compLog, panicMsgServer) 144 } 145 }() 146 } 147 } 148 149 func ParseRevision(ref string) string { 150 refParts := strings.SplitN(ref, "/", 3) 151 return refParts[len(refParts)-1] 152 } 153 154 // affectedRevisionInfo examines a payload from a webhook event, and extracts the repo web URL, 155 // the revision, and whether, or not this affected origin/HEAD (the default branch of the repository) 156 func (a *ArgoCDWebhookHandler) affectedRevisionInfo(payloadIf any) (webURLs []string, revision string, change changeInfo, touchedHead bool, changedFiles []string) { 157 switch payload := payloadIf.(type) { 158 case azuredevops.GitPushEvent: 159 // See: https://learn.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops#git.push 160 webURLs = append(webURLs, payload.Resource.Repository.RemoteURL) 161 if len(payload.Resource.RefUpdates) > 0 { 162 revision = ParseRevision(payload.Resource.RefUpdates[0].Name) 163 change.shaAfter = ParseRevision(payload.Resource.RefUpdates[0].NewObjectID) 164 change.shaBefore = ParseRevision(payload.Resource.RefUpdates[0].OldObjectID) 165 touchedHead = payload.Resource.RefUpdates[0].Name == payload.Resource.Repository.DefaultBranch 166 } 167 // unfortunately, Azure DevOps doesn't provide a list of changed files 168 case github.PushPayload: 169 // See: https://developer.github.com/v3/activity/events/types/#pushevent 170 webURLs = append(webURLs, payload.Repository.HTMLURL) 171 revision = ParseRevision(payload.Ref) 172 change.shaAfter = ParseRevision(payload.After) 173 change.shaBefore = ParseRevision(payload.Before) 174 touchedHead = bool(payload.Repository.DefaultBranch == revision) 175 for _, commit := range payload.Commits { 176 changedFiles = append(changedFiles, commit.Added...) 177 changedFiles = append(changedFiles, commit.Modified...) 178 changedFiles = append(changedFiles, commit.Removed...) 179 } 180 case gitlab.PushEventPayload: 181 // See: https://docs.gitlab.com/ee/user/project/integrations/webhooks.html 182 webURLs = append(webURLs, payload.Project.WebURL) 183 revision = ParseRevision(payload.Ref) 184 change.shaAfter = ParseRevision(payload.After) 185 change.shaBefore = ParseRevision(payload.Before) 186 touchedHead = bool(payload.Project.DefaultBranch == revision) 187 for _, commit := range payload.Commits { 188 changedFiles = append(changedFiles, commit.Added...) 189 changedFiles = append(changedFiles, commit.Modified...) 190 changedFiles = append(changedFiles, commit.Removed...) 191 } 192 case gitlab.TagEventPayload: 193 // See: https://docs.gitlab.com/ee/user/project/integrations/webhooks.html 194 // NOTE: this is untested 195 webURLs = append(webURLs, payload.Project.WebURL) 196 revision = ParseRevision(payload.Ref) 197 change.shaAfter = ParseRevision(payload.After) 198 change.shaBefore = ParseRevision(payload.Before) 199 touchedHead = bool(payload.Project.DefaultBranch == revision) 200 for _, commit := range payload.Commits { 201 changedFiles = append(changedFiles, commit.Added...) 202 changedFiles = append(changedFiles, commit.Modified...) 203 changedFiles = append(changedFiles, commit.Removed...) 204 } 205 case bitbucket.RepoPushPayload: 206 // See: https://confluence.atlassian.com/bitbucket/event-payloads-740262817.html#EventPayloads-Push 207 // NOTE: this is untested 208 webURLs = append(webURLs, payload.Repository.Links.HTML.Href) 209 for _, changes := range payload.Push.Changes { 210 revision = changes.New.Name 211 change.shaBefore = changes.Old.Target.Hash 212 change.shaAfter = changes.New.Target.Hash 213 break 214 } 215 // Not actually sure how to check if the incoming change affected HEAD just by examining the 216 // payload alone. To be safe, we just return true and let the controller check for himself. 217 touchedHead = true 218 219 // Get DiffSet only for authenticated webhooks. 220 // when WebhookBitbucketUUID is set in argocd-secret, then the payload must be signed and 221 // signature is validated before payload is parsed. 222 if a.settings.GetWebhookBitbucketUUID() != "" { 223 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 224 defer cancel() 225 argoRepo, err := a.lookupRepository(ctx, webURLs[0]) 226 if err != nil { 227 log.Warnf("error trying to find a matching repo for URL %s: %v", payload.Repository.Links.HTML.Href, err) 228 break 229 } 230 if argoRepo == nil { 231 // it could be a public repository with no repo creds stored. 232 // initialize with empty bearer token to use the no auth bitbucket client. 233 log.Debugf("no bitbucket repository configured for URL %s, initializing with empty bearer token", webURLs[0]) 234 argoRepo = &v1alpha1.Repository{BearerToken: "", Repo: webURLs[0]} 235 } 236 apiBaseURL := strings.ReplaceAll(payload.Repository.Links.Self.Href, "/repositories/"+payload.Repository.FullName, "") 237 bbClient, err := newBitbucketClient(ctx, argoRepo, apiBaseURL) 238 if err != nil { 239 log.Warnf("error creating Bitbucket client for repo %s: %v", payload.Repository.Name, err) 240 break 241 } 242 log.Debugf("created bitbucket client with base URL '%s'", apiBaseURL) 243 owner := strings.ReplaceAll(payload.Repository.FullName, "/"+payload.Repository.Name, "") 244 spec := change.shaBefore + ".." + change.shaAfter 245 diffStatChangedFiles, err := fetchDiffStatFromBitbucket(ctx, bbClient, owner, payload.Repository.Name, spec) 246 if err != nil { 247 log.Warnf("error fetching changed files using bitbucket diffstat api: %v", err) 248 } 249 changedFiles = append(changedFiles, diffStatChangedFiles...) 250 touchedHead, err = isHeadTouched(ctx, bbClient, owner, payload.Repository.Name, revision) 251 if err != nil { 252 log.Warnf("error fetching bitbucket repo details: %v", err) 253 // To be safe, we just return true and let the controller check for himself. 254 touchedHead = true 255 } 256 } 257 258 // Bitbucket does not include a list of changed files anywhere in it's payload 259 // so we cannot update changedFiles for this type of payload 260 case bitbucketserver.RepositoryReferenceChangedPayload: 261 262 // Webhook module does not parse the inner links 263 if payload.Repository.Links != nil { 264 clone, ok := payload.Repository.Links["clone"].([]any) 265 if ok { 266 for _, l := range clone { 267 link := l.(map[string]any) 268 if link["name"] == "http" || link["name"] == "ssh" { 269 if href, ok := link["href"].(string); ok { 270 webURLs = append(webURLs, href) 271 } 272 } 273 } 274 } 275 } 276 277 // TODO: bitbucket includes multiple changes as part of a single event. 278 // We only pick the first but need to consider how to handle multiple 279 for _, change := range payload.Changes { 280 revision = ParseRevision(change.Reference.ID) 281 break 282 } 283 // Not actually sure how to check if the incoming change affected HEAD just by examining the 284 // payload alone. To be safe, we just return true and let the controller check for himself. 285 touchedHead = true 286 287 // Bitbucket does not include a list of changed files anywhere in it's payload 288 // so we cannot update changedFiles for this type of payload 289 290 case gogsclient.PushPayload: 291 revision = ParseRevision(payload.Ref) 292 change.shaAfter = ParseRevision(payload.After) 293 change.shaBefore = ParseRevision(payload.Before) 294 if payload.Repo != nil { 295 webURLs = append(webURLs, payload.Repo.HTMLURL) 296 touchedHead = payload.Repo.DefaultBranch == revision 297 } 298 for _, commit := range payload.Commits { 299 changedFiles = append(changedFiles, commit.Added...) 300 changedFiles = append(changedFiles, commit.Modified...) 301 changedFiles = append(changedFiles, commit.Removed...) 302 } 303 } 304 return webURLs, revision, change, touchedHead, changedFiles 305 } 306 307 type changeInfo struct { 308 shaBefore string 309 shaAfter string 310 } 311 312 // HandleEvent handles webhook events for repo push events 313 func (a *ArgoCDWebhookHandler) HandleEvent(payload any) { 314 webURLs, revision, change, touchedHead, changedFiles := a.affectedRevisionInfo(payload) 315 // NOTE: the webURL does not include the .git extension 316 if len(webURLs) == 0 { 317 log.Info("Ignoring webhook event") 318 return 319 } 320 for _, webURL := range webURLs { 321 log.Infof("Received push event repo: %s, revision: %s, touchedHead: %v", webURL, revision, touchedHead) 322 } 323 324 nsFilter := a.ns 325 if len(a.appNs) > 0 { 326 // Retrieve app from all namespaces 327 nsFilter = "" 328 } 329 330 appIf := a.appsLister.Applications(nsFilter) 331 apps, err := appIf.List(labels.Everything()) 332 if err != nil { 333 log.Warnf("Failed to list applications: %v", err) 334 return 335 } 336 337 installationID, err := a.settingsSrc.GetInstallationID() 338 if err != nil { 339 log.Warnf("Failed to get installation ID: %v", err) 340 return 341 } 342 trackingMethod, err := a.settingsSrc.GetTrackingMethod() 343 if err != nil { 344 log.Warnf("Failed to get trackingMethod: %v", err) 345 return 346 } 347 appInstanceLabelKey, err := a.settingsSrc.GetAppInstanceLabelKey() 348 if err != nil { 349 log.Warnf("Failed to get appInstanceLabelKey: %v", err) 350 return 351 } 352 353 // Skip any application that is neither in the control plane's namespace 354 // nor in the list of enabled namespaces. 355 var filteredApps []v1alpha1.Application 356 for _, app := range apps { 357 if app.Namespace == a.ns || glob.MatchStringInList(a.appNs, app.Namespace, glob.REGEXP) { 358 filteredApps = append(filteredApps, *app) 359 } 360 } 361 362 for _, webURL := range webURLs { 363 repoRegexp, err := GetWebURLRegex(webURL) 364 if err != nil { 365 log.Warnf("Failed to get repoRegexp: %s", err) 366 continue 367 } 368 for _, app := range filteredApps { 369 if app.Spec.SourceHydrator != nil { 370 drySource := app.Spec.SourceHydrator.GetDrySource() 371 if sourceRevisionHasChanged(drySource, revision, touchedHead) && sourceUsesURL(drySource, webURL, repoRegexp) { 372 refreshPaths := path.GetAppRefreshPaths(&app) 373 if path.AppFilesHaveChanged(refreshPaths, changedFiles) { 374 namespacedAppInterface := a.appClientset.ArgoprojV1alpha1().Applications(app.Namespace) 375 log.Infof("webhook trigger refresh app to hydrate '%s'", app.Name) 376 _, err = argo.RefreshApp(namespacedAppInterface, app.Name, v1alpha1.RefreshTypeNormal, true) 377 if err != nil { 378 log.Warnf("Failed to hydrate app '%s' for controller reprocessing: %v", app.Name, err) 379 continue 380 } 381 } 382 } 383 } 384 385 for _, source := range app.Spec.GetSources() { 386 if sourceRevisionHasChanged(source, revision, touchedHead) && sourceUsesURL(source, webURL, repoRegexp) { 387 refreshPaths := path.GetAppRefreshPaths(&app) 388 if path.AppFilesHaveChanged(refreshPaths, changedFiles) { 389 namespacedAppInterface := a.appClientset.ArgoprojV1alpha1().Applications(app.Namespace) 390 _, err = argo.RefreshApp(namespacedAppInterface, app.Name, v1alpha1.RefreshTypeNormal, true) 391 if err != nil { 392 log.Warnf("Failed to refresh app '%s' for controller reprocessing: %v", app.Name, err) 393 continue 394 } 395 // No need to refresh multiple times if multiple sources match. 396 break 397 } else if change.shaBefore != "" && change.shaAfter != "" { 398 if err := a.storePreviouslyCachedManifests(&app, change, trackingMethod, appInstanceLabelKey, installationID); err != nil { 399 log.Warnf("Failed to store cached manifests of previous revision for app '%s': %v", app.Name, err) 400 } 401 } 402 } 403 } 404 } 405 } 406 } 407 408 // GetWebURLRegex compiles a regex that will match any targetRevision referring to the same repo as 409 // the given webURL. webURL is expected to be a URL from an SCM webhook payload pointing to the web 410 // page for the repo. 411 func GetWebURLRegex(webURL string) (*regexp.Regexp, error) { 412 // 1. Optional: protocol (`http`, `https`, or `ssh`) followed by `://` 413 // 2. Optional: username followed by `@` 414 // 3. Optional: `ssh` or `altssh` subdomain 415 // 4. Required: hostname parsed from `webURL` 416 // 5. Optional: `:` followed by port number 417 // 6. Required: `:` or `/` 418 // 7. Required: path parsed from `webURL` 419 // 8. Optional: `.git` extension 420 return getURLRegex(webURL, `(?i)^((https?|ssh)://)?(%[1]s@)?((alt)?ssh\.)?%[2]s(:\d+)?[:/]%[3]s(\.git)?$`) 421 } 422 423 // GetAPIURLRegex compiles a regex that will match any targetRevision referring to the same repo as 424 // the given apiURL. 425 func GetAPIURLRegex(apiURL string) (*regexp.Regexp, error) { 426 // 1. Optional: protocol (`http` or `https`) followed by `://` 427 // 2. Optional: username followed by `@` 428 // 3. Required: hostname parsed from `webURL` 429 // 4. Optional: `:` followed by port number 430 // 5. Optional: `/` 431 return getURLRegex(apiURL, `(?i)^(https?://)?(%[1]s@)?%[2]s(:\d+)?/?$`) 432 } 433 434 func getURLRegex(originalURL string, regexpFormat string) (*regexp.Regexp, error) { 435 urlObj, err := url.Parse(originalURL) 436 if err != nil { 437 return nil, fmt.Errorf("failed to parse URL '%s'", originalURL) 438 } 439 440 regexEscapedHostname := regexp.QuoteMeta(urlObj.Hostname()) 441 const urlPathSeparator = "/" 442 regexEscapedPath := regexp.QuoteMeta(strings.TrimPrefix(urlObj.EscapedPath(), urlPathSeparator)) 443 regexpStr := fmt.Sprintf(regexpFormat, usernameRegex, regexEscapedHostname, regexEscapedPath) 444 repoRegexp, err := regexp.Compile(regexpStr) 445 if err != nil { 446 return nil, fmt.Errorf("failed to compile regexp for URL '%s'", originalURL) 447 } 448 449 return repoRegexp, nil 450 } 451 452 func (a *ArgoCDWebhookHandler) storePreviouslyCachedManifests(app *v1alpha1.Application, change changeInfo, trackingMethod string, appInstanceLabelKey string, installationID string) error { 453 destCluster, err := argo.GetDestinationCluster(context.Background(), app.Spec.Destination, a.db) 454 if err != nil { 455 return fmt.Errorf("error validating destination: %w", err) 456 } 457 458 var clusterInfo v1alpha1.ClusterInfo 459 err = a.serverCache.GetClusterInfo(destCluster.Server, &clusterInfo) 460 if err != nil { 461 return fmt.Errorf("error getting cluster info: %w", err) 462 } 463 464 var sources v1alpha1.ApplicationSources 465 if app.Spec.HasMultipleSources() { 466 sources = app.Spec.GetSources() 467 } else { 468 sources = append(sources, app.Spec.GetSource()) 469 } 470 471 refSources, err := argo.GetRefSources(context.Background(), sources, app.Spec.Project, a.db.GetRepository, []string{}) 472 if err != nil { 473 return fmt.Errorf("error getting ref sources: %w", err) 474 } 475 source := app.Spec.GetSource() 476 cache.LogDebugManifestCacheKeyFields("moving manifests cache", "webhook app revision changed", change.shaBefore, &source, refSources, &clusterInfo, app.Spec.Destination.Namespace, trackingMethod, appInstanceLabelKey, app.Name, nil) 477 478 if err := a.repoCache.SetNewRevisionManifests(change.shaAfter, change.shaBefore, &source, refSources, &clusterInfo, app.Spec.Destination.Namespace, trackingMethod, appInstanceLabelKey, app.Name, nil, installationID); err != nil { 479 return fmt.Errorf("error setting new revision manifests: %w", err) 480 } 481 482 return nil 483 } 484 485 // lookupRepository returns a repository with its credentials for a given URL. If there are no matching repository secret found, 486 // then nil repository is returned. 487 func (a *ArgoCDWebhookHandler) lookupRepository(ctx context.Context, repoURL string) (*v1alpha1.Repository, error) { 488 repositories, err := a.db.ListRepositories(ctx) 489 if err != nil { 490 return nil, fmt.Errorf("error listing repositories: %w", err) 491 } 492 var repository *v1alpha1.Repository 493 for _, repo := range repositories { 494 if git.SameURL(repo.Repo, repoURL) { 495 log.Debugf("found a matching repository for URL %s", repoURL) 496 return repo, nil 497 } 498 } 499 return repository, nil 500 } 501 502 func sourceRevisionHasChanged(source v1alpha1.ApplicationSource, revision string, touchedHead bool) bool { 503 targetRev := ParseRevision(source.TargetRevision) 504 if targetRev == "HEAD" || targetRev == "" { // revision is head 505 return touchedHead 506 } 507 targetRevisionHasPrefixList := []string{"refs/heads/", "refs/tags/"} 508 for _, prefix := range targetRevisionHasPrefixList { 509 if strings.HasPrefix(source.TargetRevision, prefix) { 510 return compareRevisions(revision, targetRev) 511 } 512 } 513 514 return compareRevisions(revision, source.TargetRevision) 515 } 516 517 func compareRevisions(revision string, targetRevision string) bool { 518 if revision == targetRevision { 519 return true 520 } 521 522 // If basic equality checking fails, it might be that the target revision is 523 // a semver version constraint 524 constraint, err := semver.NewConstraint(targetRevision) 525 if err != nil { 526 // The target revision is not a constraint 527 return false 528 } 529 530 version, err := semver.NewVersion(revision) 531 if err != nil { 532 // The new revision is not a valid semver version, so it can't match the constraint. 533 return false 534 } 535 536 return constraint.Check(version) 537 } 538 539 func sourceUsesURL(source v1alpha1.ApplicationSource, webURL string, repoRegexp *regexp.Regexp) bool { 540 if !repoRegexp.MatchString(source.RepoURL) { 541 log.Debugf("%s does not match %s", source.RepoURL, repoRegexp.String()) 542 return false 543 } 544 545 log.Debugf("%s uses repoURL %s", source.RepoURL, webURL) 546 return true 547 } 548 549 // newBitbucketClient creates a new bitbucket client for the given repository and uses the provided apiURL to connect 550 // to the bitbucket server. If the repository uses basic auth, then a basic auth client is created or if bearer token 551 // is provided, then oauth based client is created. 552 func newBitbucketClient(_ context.Context, repository *v1alpha1.Repository, apiBaseURL string) (*bb.Client, error) { 553 var bbClient *bb.Client 554 if repository.Username != "" && repository.Password != "" { 555 log.Debugf("fetched user/password for repository URL '%s', initializing basic auth client", repository.Repo) 556 if repository.Username == "x-token-auth" { 557 bbClient = bb.NewOAuthbearerToken(repository.Password) 558 } else { 559 bbClient = bb.NewBasicAuth(repository.Username, repository.Password) 560 } 561 } else { 562 if repository.BearerToken != "" { 563 log.Debugf("fetched bearer token for repository URL '%s', initializing bearer token auth based client", repository.Repo) 564 } else { 565 log.Debugf("no credentials available for repository URL '%s', initializing no auth client", repository.Repo) 566 } 567 bbClient = bb.NewOAuthbearerToken(repository.BearerToken) 568 } 569 // parse and set the target URL of the Bitbucket server in the client 570 repoBaseURL, err := url.Parse(apiBaseURL) 571 if err != nil { 572 return nil, fmt.Errorf("failed to parse bitbucket api base URL '%s'", apiBaseURL) 573 } 574 bbClient.SetApiBaseURL(*repoBaseURL) 575 return bbClient, nil 576 } 577 578 // fetchDiffStatFromBitbucket gets the list of files changed between two commits, by making a diffstat api callback to the 579 // bitbucket server from where the webhook orignated. 580 func fetchDiffStatFromBitbucket(_ context.Context, bbClient *bb.Client, owner, repoSlug, spec string) ([]string, error) { 581 // Getting the files changed from diff API: 582 // https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-diffstat-spec-get 583 584 // invoke the diffstat api call to get the list of changed files between two commit shas 585 log.Debugf("invoking diffstat call with parameters: [Owner:%s, RepoSlug:%s, Spec:%s]", owner, repoSlug, spec) 586 diffStatResp, err := bbClient.Repositories.Diff.GetDiffStat(&bb.DiffStatOptions{ 587 Owner: owner, 588 RepoSlug: repoSlug, 589 Spec: spec, 590 Renames: true, 591 }) 592 if err != nil { 593 return nil, fmt.Errorf("error getting the diffstat: %w", err) 594 } 595 changedFiles := make([]string, len(diffStatResp.DiffStats)) 596 for i, value := range diffStatResp.DiffStats { 597 changedFilePath := value.New["path"] 598 if changedFilePath != nil { 599 changedFiles[i] = changedFilePath.(string) 600 } 601 } 602 log.Debugf("changed files for spec %s: %v", spec, changedFiles) 603 return changedFiles, nil 604 } 605 606 // isHeadTouched returns true if the repository's main branch is modified, false otherwise 607 func isHeadTouched(ctx context.Context, bbClient *bb.Client, owner, repoSlug, revision string) (bool, error) { 608 bbRepoOptions := &bb.RepositoryOptions{ 609 Owner: owner, 610 RepoSlug: repoSlug, 611 } 612 bbRepo, err := bbClient.Repositories.Repository.Get(bbRepoOptions.WithContext(ctx)) 613 if err != nil { 614 return false, err 615 } 616 return bbRepo.Mainbranch.Name == revision, nil 617 } 618 619 func (a *ArgoCDWebhookHandler) Handler(w http.ResponseWriter, r *http.Request) { 620 var payload any 621 var err error 622 623 r.Body = http.MaxBytesReader(w, r.Body, a.maxWebhookPayloadSizeB) 624 625 switch { 626 case r.Header.Get("X-Vss-Activityid") != "": 627 payload, err = a.azuredevops.Parse(r, azuredevops.GitPushEventType) 628 if errors.Is(err, azuredevops.ErrBasicAuthVerificationFailed) { 629 log.WithField(common.SecurityField, common.SecurityHigh).Infof("Azure DevOps webhook basic auth verification failed") 630 } 631 // Gogs needs to be checked before GitHub since it carries both Gogs and (incompatible) GitHub headers 632 case r.Header.Get("X-Gogs-Event") != "": 633 payload, err = a.gogs.Parse(r, gogs.PushEvent) 634 if errors.Is(err, gogs.ErrHMACVerificationFailed) { 635 log.WithField(common.SecurityField, common.SecurityHigh).Infof("Gogs webhook HMAC verification failed") 636 } 637 case r.Header.Get("X-GitHub-Event") != "": 638 payload, err = a.github.Parse(r, github.PushEvent, github.PingEvent) 639 if errors.Is(err, github.ErrHMACVerificationFailed) { 640 log.WithField(common.SecurityField, common.SecurityHigh).Infof("GitHub webhook HMAC verification failed") 641 } 642 case r.Header.Get("X-Gitlab-Event") != "": 643 payload, err = a.gitlab.Parse(r, gitlab.PushEvents, gitlab.TagEvents, gitlab.SystemHookEvents) 644 if errors.Is(err, gitlab.ErrGitLabTokenVerificationFailed) { 645 log.WithField(common.SecurityField, common.SecurityHigh).Infof("GitLab webhook token verification failed") 646 } 647 case r.Header.Get("X-Hook-UUID") != "": 648 payload, err = a.bitbucket.Parse(r, bitbucket.RepoPushEvent) 649 if errors.Is(err, bitbucket.ErrUUIDVerificationFailed) { 650 log.WithField(common.SecurityField, common.SecurityHigh).Infof("BitBucket webhook UUID verification failed") 651 } 652 case r.Header.Get("X-Event-Key") != "": 653 payload, err = a.bitbucketserver.Parse(r, bitbucketserver.RepositoryReferenceChangedEvent, bitbucketserver.DiagnosticsPingEvent) 654 if errors.Is(err, bitbucketserver.ErrHMACVerificationFailed) { 655 log.WithField(common.SecurityField, common.SecurityHigh).Infof("BitBucket webhook HMAC verification failed") 656 } 657 default: 658 log.Debug("Ignoring unknown webhook event") 659 http.Error(w, "Unknown webhook event", http.StatusBadRequest) 660 return 661 } 662 663 if err != nil { 664 // If the error is due to a large payload, return a more user-friendly error message 665 if err.Error() == "error parsing payload" { 666 msg := fmt.Sprintf("Webhook processing failed: The payload is either too large or corrupted. Please check the payload size (must be under %v MB) and ensure it is valid JSON", a.maxWebhookPayloadSizeB/1024/1024) 667 log.WithField(common.SecurityField, common.SecurityHigh).Warn(msg) 668 http.Error(w, msg, http.StatusBadRequest) 669 return 670 } 671 672 log.Infof("Webhook processing failed: %s", err) 673 status := http.StatusBadRequest 674 if r.Method != http.MethodPost { 675 status = http.StatusMethodNotAllowed 676 } 677 http.Error(w, "Webhook processing failed: "+html.EscapeString(err.Error()), status) 678 return 679 } 680 681 select { 682 case a.queue <- payload: 683 default: 684 log.Info("Queue is full, discarding webhook payload") 685 http.Error(w, "Queue is full, discarding webhook payload", http.StatusServiceUnavailable) 686 } 687 }