From d974585f7edbd3cd75b06309154706ea6ecd8b6f Mon Sep 17 00:00:00 2001 From: Nikolas Falco Date: Fri, 16 May 2025 11:42:22 +0200 Subject: [PATCH] [JENKINS-74983] Add support for authenticated Webhooks registered in Bitbucket Verify in the webhooks processor when the signature is present is matches the configured Add configuration in the global settings to setup HMAC credentials. The secret is not customisable by single project for the following reasons: * events should contains a duplicate of the payload to be verified only in BitbucketSCMSource.retrieve method. * that means spend a lot of resources just to ignore the payload. Multiple fake requests, would overload Jenkins that have to process events to lookup the right project. * could not response to Bitbucket that the payload is invalid because events are managed async. * each event could serve multiple projects that potentially could be configured with a different secret. --- docs/USER_GUIDE.adoc | 48 +++-- docs/images/screenshot-20.png | Bin 0 -> 28247 bytes pom.xml | 17 +- .../bitbucket/BitbucketSCMNavigator.java | 2 +- .../plugins/bitbucket/BitbucketSCMSource.java | 2 +- .../plugins/bitbucket/api/BitbucketApi.java | 3 +- .../bitbucket/api/BitbucketWebHook.java | 12 ++ .../repository/BitbucketRepositoryHook.java | 10 ++ .../endpoints/AbstractBitbucketEndpoint.java | 71 ++++++-- .../AbstractBitbucketEndpointDescriptor.java | 46 ++++- .../BitbucketEndpointConfiguration.java | 28 +-- .../endpoints/BitbucketServerEndpoint.java | 2 +- .../BitbucketSCMSourcePushHookReceiver.java | 100 ++++++++++- .../bitbucket/hooks/HookProcessor.java | 4 +- .../hooks/WebhookAutoRegisterListener.java | 11 +- .../bitbucket/hooks/WebhookConfiguration.java | 90 ++++++---- .../repository/BitbucketServerWebhook.java | 14 ++ .../NativeBitbucketServerWebhook.java | 14 ++ .../AbstractBitbucketEndpoint/config.jelly | 51 ++++-- .../help-enableHookSignature.html | 4 + .../help-hookSignatureCredentialsId.html | 9 + .../manage-hooks-detail.jelly | 6 +- .../bitbucket/BitbucketMockApiFactory.java | 10 +- .../client/BitbucketCloudApiClientTest.java | 22 +-- .../BitbucketIntegrationClientFactory.java | 61 ++++++- ...stractBitbucketEndpointDescriptorTest.java | 18 +- ...itbucketSCMSourcePushHookReceiverTest.java | 166 +++++++++++++++--- .../WebhookAutoRegisterListenerTest.java | 134 ++++++++++++++ .../client/BitbucketServerAPIClientTest.java | 25 +-- .../test/util/BitbucketTestUtil.java | 67 +++++++ .../bitbucket/hooks/cloud/signed_payload.json | 1 + .../hooks/native/signed_payload.json | 1 + ...test-repos-webhooks_start_0_limit_200.json | 24 +++ 33 files changed, 874 insertions(+), 199 deletions(-) create mode 100644 docs/images/screenshot-20.png create mode 100644 src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/help-enableHookSignature.html create mode 100644 src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/help-hookSignatureCredentialsId.html create mode 100644 src/test/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookAutoRegisterListenerTest.java create mode 100644 src/test/java/com/cloudbees/jenkins/plugins/bitbucket/test/util/BitbucketTestUtil.java create mode 100644 src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/hooks/cloud/signed_payload.json create mode 100644 src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/hooks/native/signed_payload.json create mode 100644 src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/server/payload/1.0-projects-amuniz-repos-test-repos-webhooks_start_0_limit_200.json diff --git a/docs/USER_GUIDE.adoc b/docs/USER_GUIDE.adoc index 0b6b3885e..a29e2f0e9 100644 --- a/docs/USER_GUIDE.adoc +++ b/docs/USER_GUIDE.adoc @@ -45,20 +45,20 @@ Follow these steps to create a multi-branch project with Bitbucket as a source: . Create the multi-branch project. This step depends on which multi-branch plugin is installed. For example, "Multibranch Pipeline" should be available as a project type if Pipeline Multibranch plugin is installed. + -image::images/screenshot-1.png[scaledwidth=90%] +image::images/screenshot-1.png . Select "Bitbucket" as _Branch Source_ + -image::images/screenshot-2.png[scaledwidth=90%] +image::images/screenshot-2.png . Set credentials to access Bitbucket API and checkout sources (see "Credentials configuration" section below). . Set the repository owner and name that will be monitored for branches and pull requests. + -image::images/screenshot-4.png[scaledwidth=90%] +image::images/screenshot-4.png . Finally, save the project. The initial indexing process will run and create projects for branches and pull requests. + -image::images/screenshot-5.png[scaledwidth=90%] +image::images/screenshot-5.png [id=bitbucket-scm-navigator] == Organization folders @@ -76,12 +76,12 @@ image::images/screenshot-6.png[scaledwidth=70%] .. A Bitbucket Data Center Project ID: all repositories in the project are imported as Multibranch projects. *Note that the project ID needs to be used instead of the project name*. .. A regular username: all repositories which the username is owner of are imported. + -image::images/screenshot-8.png[scaledwidth=90%] +image::images/screenshot-8.png . Save the configuration. The initial indexing process starts. Once it finishes, a Multibranch project is created for each repository. + -image::images/screenshot-9.png[scaledwidth=90%] +image::images/screenshot-9.png [id=bitbucket-avatar] == Avatar @@ -89,7 +89,7 @@ image::images/screenshot-9.png[scaledwidth=90%] This plugin have customized icon designed for the "Organization Folder" image:/src/main/webapp/images/bitbucket-logo.svg[icon,20,20], for "Multibranch Pipeline", for Single Repository image:/src/main/webapp/images/bitbucket-repository-git.svg[icon,20,20] and Folder image:/src/main/webapp/images/bitbucket-scmnavigator.svg[icon,20,20] project type. This is the default behaviour of the plugin starting from version 935. It is possible associate the Bitbucket avatar to the project item selecting the "Show Bitbucket avatar images" behaviour in the project configuration. -image::images/screenshot-19.png[scaledwidth=90%] +image::images/screenshot-19.png The supported avatars are: @@ -121,26 +121,38 @@ For Bitbucket Data Center only it is possible chose which webhooks implementatio - Plugin implementation relies on the configuration available via specific APIs provided by the https://marketplace.atlassian.com/apps/1215474/post-webhooks-for-bitbucket?tab=overview&hosting=datacenter[Post Webhooks for Bitbucket] plugin itself. To get it worked plugin must be already pre-installed on the server instance. This provider allows custom settings managed by the _ignore committers_ trait. _Note: This specific implementation will be moved to an individual repository as soon as https://issues.jenkins.io/browse/JENKINS-74913[JENKINS-74913] is implemented._ -image::images/screenshot-14.png[scaledwidth=90%] +image::images/screenshot-14.png For both Bitbucket _Multibranch Pipelines_ and _Organization folders_ there is an "Override hook management" behavior to opt out or adjust system-wide settings. -image::images/screenshot-18.png[scaledwidth=90%] +image::images/screenshot-18.png IMPORTANT: In order to have the auto-registering process working fine the Jenkins base URL must be properly configured in _Manage Jenkins_ » _System_ +=== Webhooks signature + +Once Jenkins is configured to receive payloads, it will listen for any delivery that's sent to the endpoint you configured. For security reasons, you should only process deliveries from Bitbucket. +To ensure your self-hosted server only processes deliveries from Bitbucket, you need to: +* Create a secret token for a webhook +* Enable hooks signature verification for the chosen Bitbucket Endpoints +* Select the secret token create at point 1, only _String credentials_ are taken into account. + +Any incoming webhook payloads from that given endpoint will be validated against the configured token, to verify they are coming from the configured Bitbucket endpoint URL. + +image::images/screenshot-20.png + [id=bitbucket-creds-config] == Credentials configuration The plugin (for both _Bitbucket multibranch pipelines_ and _Bitbucket Workspace/Project organization folders_) requires a credential to be configured to scan branches. It will also be the default credential to use when checking out sources. -image::images/screenshot-3.png[scaledwidth=90%] +image::images/screenshot-3.png As the `Checkout Credential` configuration was removed in commit (link:https://github.com/jenkinsci/bitbucket-branch-source-plugin/commit/a4c6bf39b83168ff62fc622bd4084ef90cf810c0[a4c6bf3]), you can alternatively add a `Checkout over SSH` behavior in the configuration of Behaviours, so that to configure a seperate SSH credential for checking out sources. -image::images/screenshot-7.png[scaledwidth=90%] +image::images/screenshot-7.png === Access Token @@ -154,13 +166,13 @@ First, create a new _access token_ in Bitbucket as instructed in one of the foll At least allow _read_ access for repositories. If you want the plugin to install the webhooks, allow _Read and write_ access for Webhooks. -image::images/screenshot-16.png[scaledwidth=90%] +image::images/screenshot-16.png Then create a new _Secret text_ credential in Jenkins, enter the Bitbucket token generated in the previous steps in the _Secret_ field. If you want be able to perform git push operation from CLI than you have to setup _write_ access for repositories. Than configure the _Custom user name/e-mail address_ trait with the Repository Access Token email generated when you created the Repository Access Token (for example, 52c16467c5f19101ff2061cc@bots.bitbucket.org). -image::images/screenshot-17.png[scaledwidth=90%] +image::images/screenshot-17.png === Personal Access Token @@ -190,13 +202,13 @@ The plugin can make use of OAuth credentials (Bitbucket Cloud only) instead of t First create a new _OAuth consumer_ in Bitbucket as instructed in the https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html[Bitbucket OAuth Documentation]. Don't forget to check _This is a private consumer_ and at least allow _read_ access for repositories and pull requests. If you want the plugin to install the webhooks, also allow _read_ and _write_ access for webhooks. -image::images/screenshot-10.png[scaledwidth=90%] +image::images/screenshot-10.png Then create new _Username with password credentials_ in Jenkins, enter the Bitbucket OAuth consumer key in the _Username_ field and the Bitbucket OAuth consumer secret in the _Password_ field. -image::images/screenshot-11.png[scaledwidth=90%] +image::images/screenshot-11.png -image::images/screenshot-12.png[scaledwidth=90%] +image::images/screenshot-12.png [id=bitbucket-mirror-support] == Mirror support @@ -213,14 +225,14 @@ Cloning from the mirror can only be used with native web-hooks since plugin web- For branches and tags, the mirror sync event is used. Thus, at cloning time, the mirror is already synchronized. However, in the case of a pull request event, there is no such guarantee. The plugin optimistically assumes that the mirror is synced and the required commit hashes exist in the mirrored repository at cloning time. If the plugin can't find the required hashes, it falls back to the primary repository. -image::images/screenshot-13.png[scaledwidth=90%] +image::images/screenshot-13.png [id=bitbucket-build-status] == Bitbucket build status When a new job build starts, the plugin send notifications to Bitbucket about the build status. An "In progress" notification is sent after complete the git checkout, another notification is sent at the end of the build, the sent value depends by the build result and the configuration given by the trait. -image::images/screenshot-15.png[scaledwidth=90%] +image::images/screenshot-15.png Follow a summary of all possible values: diff --git a/docs/images/screenshot-20.png b/docs/images/screenshot-20.png new file mode 100644 index 0000000000000000000000000000000000000000..e3637ab4be704b0f4b3085ce495d4197a3c69a3d GIT binary patch literal 28247 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 Ww_$1j+fU+@An{c83F@()=l=jo){=Yx literal 0 HcmV?d00001 diff --git a/pom.xml b/pom.xml index 696493eac..58898a8ca 100644 --- a/pom.xml +++ b/pom.xml @@ -35,8 +35,6 @@ true @{project.version} - - 1 @@ -259,4 +257,19 @@ + + + eclipse + + + m2e.version + + + + + 1 + + + + diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketSCMNavigator.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketSCMNavigator.java index 5d9a14774..eee6caf3b 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketSCMNavigator.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketSCMNavigator.java @@ -221,7 +221,7 @@ public String getServerUrl() { @DataBoundSetter public void setServerUrl(@CheckForNull String serverUrl) { - serverUrl = BitbucketEndpointConfiguration.normalizeServerUrl(serverUrl); + serverUrl = BitbucketEndpointConfiguration.normalizeServerURL(serverUrl); if (serverUrl != null && !StringUtils.equals(this.serverUrl, serverUrl)) { this.serverUrl = serverUrl; resetId(); diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketSCMSource.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketSCMSource.java index 63a806d7a..2779b13de 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketSCMSource.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketSCMSource.java @@ -287,7 +287,7 @@ public String getServerUrl() { @DataBoundSetter public void setServerUrl(@CheckForNull String serverUrl) { - String url = BitbucketEndpointConfiguration.normalizeServerUrl(serverUrl); + String url = BitbucketEndpointConfiguration.normalizeServerURL(serverUrl); if (url == null) { url = BitbucketEndpointConfiguration.get().getDefaultEndpoint().getServerUrl(); } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketApi.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketApi.java index 5a3cf5e59..0f7590c4b 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketApi.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketApi.java @@ -214,10 +214,9 @@ boolean checkPathExists(@NonNull String branchOrHash, @NonNull String path) * * @return the list of webhooks registered in the repository. * @throws IOException if there was a network communications error. - * @throws InterruptedException if interrupted while waiting on remote communications. */ @NonNull - List getWebHooks() throws IOException, InterruptedException; + List getWebHooks() throws IOException; /** * Returns the team of the current owner or {@code null} if the current owner is not a team. diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketWebHook.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketWebHook.java index 6abcfcf02..1dbd83d0d 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketWebHook.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/api/BitbucketWebHook.java @@ -23,6 +23,7 @@ */ package com.cloudbees.jenkins.plugins.bitbucket.api; +import edu.umd.cs.findbugs.annotations.Nullable; import java.util.List; /** @@ -55,4 +56,15 @@ public interface BitbucketWebHook { */ String getUuid(); + /** + * Returns the secret used as the key to generate a HMAC digest value sent + * in the X-Hub-Signature header at delivery time. + * + * @return a secret + */ + @Nullable + default String getSecret() { + return null; + } + } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/repository/BitbucketRepositoryHook.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/repository/BitbucketRepositoryHook.java index 4c4338ca5..130681924 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/repository/BitbucketRepositoryHook.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/client/repository/BitbucketRepositoryHook.java @@ -34,6 +34,8 @@ public class BitbucketRepositoryHook implements BitbucketWebHook { private String url; + private String secret; + private boolean active; private List events; @@ -83,4 +85,12 @@ public void setUuid(String uuid) { this.uuid = uuid; } + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint.java index 7f8f447b1..0217b003c 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint.java @@ -24,22 +24,22 @@ package com.cloudbees.jenkins.plugins.bitbucket.endpoints; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator; -import com.cloudbees.plugins.credentials.CredentialsMatchers; -import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.jenkins.plugins.bitbucket.impl.util.BitbucketCredentials; import com.cloudbees.plugins.credentials.common.StandardCredentials; import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; -import com.cloudbees.plugins.credentials.domains.URIRequirementBuilder; import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Util; import hudson.model.AbstractDescribableImpl; -import hudson.security.ACL; import jenkins.authentication.tokens.api.AuthenticationTokens; import jenkins.model.Jenkins; import org.apache.commons.lang3.StringUtils; import org.jenkinsci.plugins.displayurlapi.ClassicDisplayURLProvider; +import org.jenkinsci.plugins.plaincredentials.StringCredentials; import org.kohsuke.stapler.DataBoundSetter; +import static hudson.Util.fixEmptyAndTrim; + /** * Represents a {@link BitbucketCloudEndpoint} or a {@link BitbucketServerEndpoint}. * @@ -58,6 +58,17 @@ public abstract class AbstractBitbucketEndpoint extends AbstractDescribableImpl< @CheckForNull private final String credentialsId; + /** + * {@code true} if and only if Jenkins have to verify the signature of all incoming hooks. + */ + private boolean enableHookSignature; + + /** + * The {@link StringCredentials#getId()} of the credentials to use to verify the signature of hooks. + */ + @CheckForNull + private String hookSignatureCredentialsId; + /** * Jenkins Server Root URL to be used by that Bitbucket endpoint. * The global setting from Jenkins.get().getRootUrl() @@ -76,7 +87,7 @@ public abstract class AbstractBitbucketEndpoint extends AbstractDescribableImpl< */ AbstractBitbucketEndpoint(boolean manageHooks, @CheckForNull String credentialsId) { this.manageHooks = manageHooks && StringUtils.isNotBlank(credentialsId); - this.credentialsId = manageHooks ? credentialsId : null; + this.credentialsId = manageHooks ? fixEmptyAndTrim(credentialsId) : null; } /** @@ -106,7 +117,7 @@ static String normalizeJenkinsRootUrl(String rootUrl) { // This routine is not really BitbucketEndpointConfiguration // specific, it just works on strings with some defaults: return Util.ensureEndsWith( - BitbucketEndpointConfiguration.normalizeServerUrl(rootUrl),"/"); + BitbucketEndpointConfiguration.normalizeServerURL(rootUrl),"/"); } /** @@ -124,7 +135,7 @@ public String getBitbucketJenkinsRootUrl() { @DataBoundSetter public void setBitbucketJenkinsRootUrl(String bitbucketJenkinsRootUrl) { if (manageHooks) { - this.bitbucketJenkinsRootUrl = Util.fixEmptyAndTrim(bitbucketJenkinsRootUrl); + this.bitbucketJenkinsRootUrl = fixEmptyAndTrim(bitbucketJenkinsRootUrl); if (this.bitbucketJenkinsRootUrl != null) { this.bitbucketJenkinsRootUrl = normalizeJenkinsRootUrl(this.bitbucketJenkinsRootUrl); } @@ -133,6 +144,29 @@ public void setBitbucketJenkinsRootUrl(String bitbucketJenkinsRootUrl) { } } + @CheckForNull + public String getHookSignatureCredentialsId() { + return hookSignatureCredentialsId; + } + + @DataBoundSetter + public void setHookSignatureCredentialsId(String hookSignatureCredentialsId) { + if (enableHookSignature) { + this.hookSignatureCredentialsId = fixEmptyAndTrim(hookSignatureCredentialsId); + } else { + this.hookSignatureCredentialsId = null; + } + } + + public boolean isEnableHookSignature() { + return enableHookSignature; + } + + @DataBoundSetter + public void setEnableHookSignature(boolean enableHookSignature) { + this.enableHookSignature = enableHookSignature; + } + /** * Jenkins Server Root URL to be used by this Bitbucket endpoint. * The global setting from Jenkins.get().getRootUrl() @@ -220,18 +254,17 @@ public final String getCredentialsId() { */ @CheckForNull public StandardCredentials credentials() { - return StringUtils.isBlank(credentialsId) ? null : CredentialsMatchers.firstOrNull( - CredentialsProvider.lookupCredentialsInItemGroup( - StandardCredentials.class, - Jenkins.get(), - ACL.SYSTEM2 , - URIRequirementBuilder.fromUri(getServerUrl()).build() - ), - CredentialsMatchers.allOf( - CredentialsMatchers.withId(credentialsId), - AuthenticationTokens.matcher(BitbucketAuthenticator.authenticationContext(getServerUrl())) - ) - ); + return BitbucketCredentials.lookupCredentials(getServerUrl(), Jenkins.get(), credentialsId, StandardCredentials.class); + } + + /** + * Looks up the {@link StringCredentials} to use to verify the signature of hooks. + * + * @return the credentials or {@code null}. + */ + @CheckForNull + public StringCredentials hookSignatureCredentials() { + return BitbucketCredentials.lookupCredentials(getServerUrl(), Jenkins.get(), hookSignatureCredentialsId, StringCredentials.class); } /** diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpointDescriptor.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpointDescriptor.java index 18aad65e7..b82be0f24 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpointDescriptor.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpointDescriptor.java @@ -24,6 +24,7 @@ package com.cloudbees.jenkins.plugins.bitbucket.endpoints; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketAuthenticator; +import com.cloudbees.plugins.credentials.CredentialsMatchers; import com.cloudbees.plugins.credentials.common.StandardCredentials; import com.cloudbees.plugins.credentials.common.StandardListBoxModel; import com.cloudbees.plugins.credentials.domains.URIRequirementBuilder; @@ -36,9 +37,11 @@ import java.net.URL; import jenkins.authentication.tokens.api.AuthenticationTokens; import jenkins.model.Jenkins; +import org.jenkinsci.plugins.plaincredentials.StringCredentials; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.interceptor.RequirePOST; /** * {@link Descriptor} base class for {@link AbstractBitbucketEndpoint} subclasses. @@ -49,12 +52,14 @@ public abstract class AbstractBitbucketEndpointDescriptor extends Descriptor endpoints = new ArrayList<>(getEndpoints()); - for (AbstractBitbucketEndpoint ep : endpoints) { + List newEndpoints = new ArrayList<>(getEndpoints()); + for (AbstractBitbucketEndpoint ep : newEndpoints) { if (ep.getServerUrl().equals(endpoint.getServerUrl())) { return false; } } - endpoints.add(endpoint); - setEndpoints(endpoints); + newEndpoints.add(endpoint); + setEndpoints(newEndpoints); return true; } @@ -211,20 +211,20 @@ public synchronized boolean addEndpoint(@NonNull AbstractBitbucketEndpoint endpo * @param endpoint the endpoint to update. */ public synchronized void updateEndpoint(@NonNull AbstractBitbucketEndpoint endpoint) { - List endpoints = new ArrayList<>(getEndpoints()); + List newEndpoints = new ArrayList<>(getEndpoints()); boolean found = false; - for (int i = 0; i < endpoints.size(); i++) { - AbstractBitbucketEndpoint ep = endpoints.get(i); + for (int i = 0; i < newEndpoints.size(); i++) { + AbstractBitbucketEndpoint ep = newEndpoints.get(i); if (ep.getServerUrl().equals(endpoint.getServerUrl())) { - endpoints.set(i, endpoint); + newEndpoints.set(i, endpoint); found = true; break; } } if (!found) { - endpoints.add(endpoint); + newEndpoints.add(endpoint); } - setEndpoints(endpoints); + setEndpoints(newEndpoints); } /** @@ -244,7 +244,7 @@ public boolean removeEndpoint(@NonNull AbstractBitbucketEndpoint endpoint) { * @return {@code true} if the list of endpoints was modified */ public synchronized boolean removeEndpoint(@CheckForNull String serverURL) { - String fixedServerURL = normalizeServerUrl(serverURL); + String fixedServerURL = normalizeServerURL(serverURL); List newEndpoints = new ArrayList<>(getEndpoints()); boolean modified = newEndpoints.removeIf(endpoint -> Objects.equals(fixedServerURL, endpoint.getServerUrl())); setEndpoints(newEndpoints); @@ -258,7 +258,7 @@ public synchronized boolean removeEndpoint(@CheckForNull String serverURL) { * @return the global configuration for the specified server url or {@code null} if not defined. */ public synchronized Optional findEndpoint(@CheckForNull String serverURL) { - serverURL = normalizeServerUrl(serverURL); + serverURL = normalizeServerURL(serverURL); for (AbstractBitbucketEndpoint endpoint : getEndpoints()) { if (Objects.equals(serverURL, endpoint.getServerUrl())) { return Optional.of(endpoint); @@ -293,7 +293,7 @@ public synchronized AbstractBitbucketEndpoint getDefaultEndpoint() { * @return the normalized server URL. */ @CheckForNull - public static String normalizeServerUrl(@CheckForNull String serverURL) { + public static String normalizeServerURL(@CheckForNull String serverURL) { if (StringUtils.isBlank(serverURL)) { return null; } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/BitbucketServerEndpoint.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/BitbucketServerEndpoint.java index 5873a2a1a..cd08fb44f 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/BitbucketServerEndpoint.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/BitbucketServerEndpoint.java @@ -111,7 +111,7 @@ public BitbucketServerEndpoint(@CheckForNull String displayName, @CheckForNull String credentialsId) { super(manageHooks, credentialsId); // use fixNull to silent nullability check - this.serverUrl = Util.fixNull(BitbucketEndpointConfiguration.normalizeServerUrl(serverUrl)); + this.serverUrl = Util.fixNull(BitbucketEndpointConfiguration.normalizeServerURL(serverUrl)); this.displayName = StringUtils.isBlank(displayName) ? SCMName.fromUrl(this.serverUrl, COMMON_PREFIX_HOSTNAMES) : displayName.trim(); diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiver.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiver.java index e442088f1..956930d52 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiver.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiver.java @@ -23,23 +23,41 @@ */ package com.cloudbees.jenkins.plugins.bitbucket.hooks; +import com.cloudbees.jenkins.plugins.bitbucket.endpoints.AbstractBitbucketEndpoint; +import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketCloudEndpoint; +import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketEndpointConfiguration; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import hudson.Extension; import hudson.model.UnprotectedRootAction; import hudson.security.csrf.CrumbExclusion; import hudson.util.HttpResponses; +import hudson.util.Secret; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.logging.Level; import java.util.logging.Logger; import jenkins.scm.api.SCMEvent; +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Hex; +import org.apache.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.jenkinsci.plugins.plaincredentials.StringCredentials; import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses.HttpResponseException; import org.kohsuke.stapler.StaplerRequest2; +import static org.apache.commons.lang.StringUtils.trimToNull; + /** * Process Bitbucket push and pull requests creations/updates hooks. */ @@ -78,6 +96,7 @@ public String getUrlName() { public HttpResponse doNotify(StaplerRequest2 req) throws IOException { String origin = SCMEvent.originOf(req); String body = IOUtils.toString(req.getInputStream(), StandardCharsets.UTF_8); + String eventKey = req.getHeader("X-Event-Key"); if (eventKey == null) { return HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "X-Event-Key HTTP header not found"); @@ -89,23 +108,98 @@ public HttpResponse doNotify(StaplerRequest2 req) throws IOException { } String bitbucketKey = req.getHeader("X-Bitbucket-Type"); - String serverUrl = req.getParameter("server_url"); + String serverURL = req.getParameter("server_url"); + BitbucketType instanceType = null; if (bitbucketKey != null) { instanceType = BitbucketType.fromString(bitbucketKey); } - if (instanceType == null && serverUrl != null) { + if (instanceType == null && serverURL != null) { LOGGER.log(Level.FINE, "server_url request parameter found. Bitbucket Native Server webhook incoming."); instanceType = BitbucketType.SERVER; } else { LOGGER.log(Level.FINE, "X-Bitbucket-Type header / server_url request parameter not found. Bitbucket Cloud webhook incoming."); + instanceType = BitbucketType.CLOUD; + serverURL = BitbucketCloudEndpoint.SERVER_URL; + } + + AbstractBitbucketEndpoint endpoint = BitbucketEndpointConfiguration.get() + .findEndpoint(serverURL) + .orElse(null); + if (endpoint != null) { + if (endpoint.isEnableHookSignature()) { + if (req.getHeader("X-Hub-Signature") != null) { + HttpResponseException error = checkSignature(req, body, endpoint); + if (error != null) { + return error; + } + } else { + return HttpResponses.error(HttpServletResponse.SC_FORBIDDEN, "Payload has not be signed, configure the webHook secret in Bitbucket as documented at https://github.com/jenkinsci/bitbucket-branch-source-plugin/blob/master/docs/USER_GUIDE.adoc#webhooks-registering"); + } + } else if (req.getHeader("X-Hub-Signature") == null) { + LOGGER.log(Level.FINER, "Signature not configured for endpoint {}.", endpoint); + } + } else { + LOGGER.log(Level.INFO, "No bitbucket endpoint found for {} to verify the signature of incoming webhook.", endpoint); } HookProcessor hookProcessor = getHookProcessor(type); - hookProcessor.process(type, body, instanceType, origin, serverUrl); + hookProcessor.process(type, body, instanceType, origin, serverURL); return HttpResponses.ok(); } + @Nullable + private HttpResponseException checkSignature(@NonNull StaplerRequest2 req, @NonNull String body, @NonNull AbstractBitbucketEndpoint endpoint) { + LOGGER.log(Level.FINE, "Payload endpoint host {}, request endpoint host {}", new Object[] { endpoint, req.getRemoteAddr() }); + + StringCredentials signatureCredentials = endpoint.hookSignatureCredentials(); + if (signatureCredentials != null) { + String signatureHeader = req.getHeader("X-Hub-Signature"); + String bitbucketAlgorithm = trimToNull(StringUtils.substringBefore(signatureHeader, "=")); + String bitbucketSignature = trimToNull(StringUtils.substringAfter(signatureHeader, "=")); + HmacAlgorithms algorithm = getAlgorithm(bitbucketAlgorithm); + if (algorithm == null) { + return HttpResponses.error(HttpServletResponse.SC_FORBIDDEN, "Signature " + bitbucketAlgorithm + " not supported"); + } + HmacUtils util; + try { + String key = Secret.toString(signatureCredentials.getSecret()); + util = new HmacUtils(algorithm, key.getBytes(StandardCharsets.UTF_8)); + byte[] digest = util.hmac(body); + if (!MessageDigest.isEqual(Hex.decodeHex(bitbucketSignature), digest)) { + return HttpResponses.error(HttpServletResponse.SC_FORBIDDEN, "Signature verification failed"); + } + } catch (IllegalArgumentException e) { + return HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "Signature method not supported: " + algorithm); + } catch (DecoderException e) { + return HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "Hex signature can not be decoded: " + bitbucketSignature); + } + } else { + String hookId = req.getHeader("X-Hook-UUID"); + String requestId = ObjectUtils.firstNonNull(req.getHeader("X-Request-UUID"), req.getHeader("X-Request-Id")); + String hookSignatureCredentialsId = endpoint.getHookSignatureCredentialsId(); + LOGGER.log(Level.WARNING, "No credentials {} found to verify the signature of incoming webhook {} request {}", new Object[] { hookSignatureCredentialsId, hookId, requestId }); + return HttpResponses.error(HttpServletResponse.SC_FORBIDDEN, "No credentials " + hookSignatureCredentialsId + " found to verify the signature"); + } + return null; + } + + @CheckForNull + private HmacAlgorithms getAlgorithm(String algorithm) { + switch (StringUtils.lowerCase(algorithm)) { + case "sha1": + return HmacAlgorithms.HMAC_SHA_1; + case "sha256": + return HmacAlgorithms.HMAC_SHA_256; + case "sha384": + return HmacAlgorithms.HMAC_SHA_384; + case "sha512": + return HmacAlgorithms.HMAC_SHA_512; + default: + return null; + } + } + /* For test purpose */ HookProcessor getHookProcessor(HookEventType type) { return type.getProcessor(); diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/HookProcessor.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/HookProcessor.java index 9617b87e0..0bcc1682e 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/HookProcessor.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/HookProcessor.java @@ -67,9 +67,9 @@ public abstract class HookProcessor { * @param payload the hook payload * @param instanceType the Bitbucket type that called the hook * @param origin the origin of the event. - * @param serverUrl special value for native Bitbucket Server hooks which don't expose the server URL in the payload. + * @param serverURL special value for native Bitbucket Server hooks which don't expose the server URL in the payload. */ - public void process(HookEventType type, String payload, BitbucketType instanceType, String origin, String serverUrl) { + public void process(HookEventType type, String payload, BitbucketType instanceType, String origin, String serverURL) { process(type, payload, instanceType, origin); } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookAutoRegisterListener.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookAutoRegisterListener.java index 6f16cbe3b..aed909377 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookAutoRegisterListener.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookAutoRegisterListener.java @@ -30,6 +30,8 @@ import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketWebHook; import com.cloudbees.jenkins.plugins.bitbucket.endpoints.AbstractBitbucketEndpoint; import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketEndpointConfiguration; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Extension; import hudson.model.Item; import hudson.model.listeners.ItemListener; @@ -101,7 +103,7 @@ private void registerHooksAsync(final SCMSourceOwner owner) { public void doRun() { try { registerHooks(owner); - } catch (IOException | InterruptedException e) { + } catch (IOException e) { LOGGER.log(Level.WARNING, "Could not register hooks for " + owner.getFullName(), e); } } @@ -122,7 +124,7 @@ public void doRun() { } // synchronized just to avoid duplicated webhooks in case SCMSourceOwner is updated repeatedly and quickly - private synchronized void registerHooks(SCMSourceOwner owner) throws IOException, InterruptedException { + private synchronized void registerHooks(SCMSourceOwner owner) throws IOException { List sources = getBitbucketSCMSources(owner); if (sources.isEmpty()) { // don't spam logs if we are irrelevant @@ -156,7 +158,7 @@ private synchronized void registerHooks(SCMSourceOwner owner) throws IOException } } - private void registerHook(BitbucketSCMSource source) throws IOException, InterruptedException { + private void registerHook(BitbucketSCMSource source) throws IOException { BitbucketApi bitbucket = bitbucketApiFor(source); if (bitbucket == null) { return; @@ -209,7 +211,8 @@ private void removeHooks(SCMSourceOwner owner) throws IOException, InterruptedEx } } - private BitbucketApi bitbucketApiFor(BitbucketSCMSource source) { + @CheckForNull + private BitbucketApi bitbucketApiFor(@NonNull BitbucketSCMSource source) { switch (new BitbucketSCMSourceContext(null, SCMHeadObserver.none()) .withTraits(source.getTraits()) .webhookRegistration()) { diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookConfiguration.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookConfiguration.java index ac2756a84..c5ef3321c 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookConfiguration.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookConfiguration.java @@ -26,19 +26,26 @@ import com.cloudbees.jenkins.plugins.bitbucket.BitbucketSCMSource; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketWebHook; import com.cloudbees.jenkins.plugins.bitbucket.client.repository.BitbucketRepositoryHook; +import com.cloudbees.jenkins.plugins.bitbucket.endpoints.AbstractBitbucketEndpoint; import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketEndpointConfiguration; import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketServerEndpoint; import com.cloudbees.jenkins.plugins.bitbucket.impl.util.BitbucketApiUtils; import com.cloudbees.jenkins.plugins.bitbucket.server.client.repository.BitbucketServerWebhook; import com.cloudbees.jenkins.plugins.bitbucket.server.client.repository.NativeBitbucketServerWebhook; import com.damnhandy.uri.template.UriTemplate; +import com.google.common.base.Objects; import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.Util; +import hudson.util.Secret; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Set; import java.util.TreeSet; +import org.jenkinsci.plugins.plaincredentials.StringCredentials; /** * Contains the webhook configuration @@ -112,43 +119,35 @@ public String getCommittersToIgnore() { } boolean updateHook(BitbucketWebHook hook, BitbucketSCMSource owner) { + boolean updated = false; + + final String signatureSecret = getSecret(owner.getServerUrl()); + if (hook instanceof BitbucketRepositoryHook cloudHook) { if (!hook.getEvents().containsAll(CLOUD_EVENTS)) { Set events = new TreeSet<>(hook.getEvents()); events.addAll(CLOUD_EVENTS); cloudHook.setEvents(new ArrayList<>(events)); - return true; + updated = true; } - - return false; - } - - if (hook instanceof BitbucketServerWebhook serverHook) { - // Handle null case - String hookCommittersToIgnore = serverHook.getCommittersToIgnore(); - if (hookCommittersToIgnore == null) { - hookCommittersToIgnore = ""; + if (!Objects.equal(hook.getSecret(), signatureSecret)) { + cloudHook.setSecret(signatureSecret); + updated = true; } - - // Handle null case - String thisCommittersToIgnore = committersToIgnore; - if (thisCommittersToIgnore == null) { - thisCommittersToIgnore = ""; + } else if (hook instanceof BitbucketServerWebhook serverHook) { + String hookCommittersToIgnore = Util.fixEmptyAndTrim(serverHook.getCommittersToIgnore()); + String thisCommittersToIgnore = Util.fixEmptyAndTrim(committersToIgnore); + if (!Objects.equal(thisCommittersToIgnore, hookCommittersToIgnore)) { + serverHook.setCommittersToIgnore(thisCommittersToIgnore); + updated = true; } - - if (!hookCommittersToIgnore.trim().equals(thisCommittersToIgnore.trim())) { - serverHook.setCommittersToIgnore(committersToIgnore); - return true; + if (!Objects.equal(serverHook.getSecret(), signatureSecret)) { + serverHook.setSecret(signatureSecret); + updated = true; } - - return false; - } - - if (hook instanceof NativeBitbucketServerWebhook serverHook) { - boolean updated = false; - - String serverUrl = owner.getServerUrl(); - String url = getNativeServerWebhookUrl(serverUrl, owner.getEndpointJenkinsRootURL()); + } else if (hook instanceof NativeBitbucketServerWebhook serverHook) { + String serverURL = owner.getServerUrl(); + String url = getNativeServerWebhookUrl(serverURL, owner.getEndpointJenkinsRootURL()); if (!url.equals(serverHook.getUrl())) { serverHook.setUrl(url); @@ -157,24 +156,28 @@ boolean updateHook(BitbucketWebHook hook, BitbucketSCMSource owner) { List events = serverHook.getEvents(); if (events == null) { - serverHook.setEvents(getNativeServerEvents(serverUrl)); + serverHook.setEvents(getNativeServerEvents(serverURL)); updated = true; - } else if (!events.containsAll(getNativeServerEvents(serverUrl))) { + } else if (!events.containsAll(getNativeServerEvents(serverURL))) { Set newEvents = new TreeSet<>(events); - newEvents.addAll(getNativeServerEvents(serverUrl)); + newEvents.addAll(getNativeServerEvents(serverURL)); serverHook.setEvents(new ArrayList<>(newEvents)); updated = true; } - return updated; + if (!Objects.equal(serverHook.getSecret(), signatureSecret)) { + serverHook.setSecret(signatureSecret); + updated = true; + } } - return false; + return updated; } public BitbucketWebHook getHook(BitbucketSCMSource owner) { final String serverUrl = owner.getServerUrl(); final String rootUrl = owner.getEndpointJenkinsRootURL(); + final String signatureSecret = getSecret(owner.getServerUrl()); if (BitbucketApiUtils.isCloud(serverUrl)) { BitbucketRepositoryHook hook = new BitbucketRepositoryHook(); @@ -182,6 +185,7 @@ public BitbucketWebHook getHook(BitbucketSCMSource owner) { hook.setActive(true); hook.setDescription(description); hook.setUrl(rootUrl + BitbucketSCMSourcePushHookReceiver.FULL_PATH); + hook.setSecret(signatureSecret); return hook; } @@ -189,9 +193,10 @@ public BitbucketWebHook getHook(BitbucketSCMSource owner) { case NATIVE: { NativeBitbucketServerWebhook hook = new NativeBitbucketServerWebhook(); hook.setActive(true); - hook.setEvents(getNativeServerEvents(serverUrl)); hook.setDescription(description); + hook.setEvents(getNativeServerEvents(serverUrl)); hook.setUrl(getNativeServerWebhookUrl(serverUrl, rootUrl)); + hook.setSecret(signatureSecret); return hook; } @@ -202,11 +207,28 @@ public BitbucketWebHook getHook(BitbucketSCMSource owner) { hook.setDescription(description); hook.setUrl(rootUrl + BitbucketSCMSourcePushHookReceiver.FULL_PATH); hook.setCommittersToIgnore(committersToIgnore); + hook.setSecret(signatureSecret); return hook; } } } + @Nullable + private String getSecret(@NonNull String serverURL) { + AbstractBitbucketEndpoint endpoint = BitbucketEndpointConfiguration.get() + .findEndpoint(serverURL) + .orElseThrow(); + if (endpoint.isEnableHookSignature()) { + StringCredentials credentials = endpoint.hookSignatureCredentials(); + if (credentials != null) { + return Secret.toString(credentials.getSecret()); + } else { + throw new IllegalStateException("Credentials " + endpoint.getHookSignatureCredentialsId() + " not found on hook registration"); + } + } + return null; + } + private static List getNativeServerEvents(String serverUrl) { BitbucketServerEndpoint endpoint = BitbucketEndpointConfiguration.get() .findEndpoint(serverUrl, BitbucketServerEndpoint.class) diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/repository/BitbucketServerWebhook.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/repository/BitbucketServerWebhook.java index 52f526b27..27613ebd3 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/repository/BitbucketServerWebhook.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/repository/BitbucketServerWebhook.java @@ -29,7 +29,9 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class BitbucketServerWebhook implements BitbucketWebHook { @JsonProperty("id") @@ -40,6 +42,8 @@ public class BitbucketServerWebhook implements BitbucketWebHook { private String url; @JsonProperty("enabled") private boolean active; + @JsonProperty("configuration") + private Map configuration = new HashMap<>(); @JsonInclude(JsonInclude.Include.NON_NULL) // If null, don't marshal to allow for backwards compatibility private String committersToIgnore; // Since Bitbucket Webhooks version 1.5.0 @@ -93,4 +97,14 @@ public String getUuid() { } return null; } + + @Override + public String getSecret() { + return configuration.get("secret"); + } + + public void setSecret(String secret) { + configuration.put("secret", secret); + } + } diff --git a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/repository/NativeBitbucketServerWebhook.java b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/repository/NativeBitbucketServerWebhook.java index 5e44d13ab..c2191e1a9 100644 --- a/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/repository/NativeBitbucketServerWebhook.java +++ b/src/main/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/repository/NativeBitbucketServerWebhook.java @@ -25,7 +25,9 @@ import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketWebHook; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class NativeBitbucketServerWebhook implements BitbucketWebHook { @@ -36,6 +38,8 @@ public class NativeBitbucketServerWebhook implements BitbucketWebHook { private String url; private List events; private boolean active; + @JsonProperty("configuration") + private Map configuration = new HashMap<>(); @Override public String getUuid() { @@ -81,4 +85,14 @@ public boolean isActive() { public void setActive(boolean active) { this.active = active; } + + @Override + public String getSecret() { + return configuration.get("secret"); + } + + public void setSecret(String secret) { + configuration.put("secret", secret); + } + } diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/config.jelly b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/config.jelly index f4e481ed9..4b869b907 100644 --- a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/config.jelly +++ b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/config.jelly @@ -1,14 +1,41 @@ + - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/help-enableHookSignature.html b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/help-enableHookSignature.html new file mode 100644 index 000000000..0c9ca8c6f --- /dev/null +++ b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/help-enableHookSignature.html @@ -0,0 +1,4 @@ +
+ Verify the signature of any incoming hook payload from this endpoint. This means that any payload must be signed, + if not payload will be rejected. +
\ No newline at end of file diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/help-hookSignatureCredentialsId.html b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/help-hookSignatureCredentialsId.html new file mode 100644 index 000000000..0af1f6438 --- /dev/null +++ b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpoint/help-hookSignatureCredentialsId.html @@ -0,0 +1,9 @@ +
+ Select the credentials to use to verify the signature of incoming hooks. Both GLOBAL and SYSTEM scoped credentials + are eligible as the management of hooks is run in the context of Jenkins itself and not in the context of the + individual items. +

+ If the automatic management of web hooks is disabled than you have to setup manually in each repository selected + credentials or any untrusted hook will be discarded. +

+
\ No newline at end of file diff --git a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/BitbucketServerEndpoint/manage-hooks-detail.jelly b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/BitbucketServerEndpoint/manage-hooks-detail.jelly index 96d1b6f1f..109342fce 100644 --- a/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/BitbucketServerEndpoint/manage-hooks-detail.jelly +++ b/src/main/resources/com/cloudbees/jenkins/plugins/bitbucket/endpoints/BitbucketServerEndpoint/manage-hooks-detail.jelly @@ -1,6 +1,6 @@ - - - + + + diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketMockApiFactory.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketMockApiFactory.java index 868f1e5ce..94d25328b 100644 --- a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketMockApiFactory.java +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/BitbucketMockApiFactory.java @@ -33,7 +33,7 @@ import hudson.ExtensionList; import java.util.HashMap; import java.util.Map; -import org.apache.commons.lang3.StringUtils; +import java.util.Objects; @Extension(ordinal = 1000) public class BitbucketMockApiFactory extends BitbucketApiFactory { @@ -45,11 +45,11 @@ public static void clear() { } public static void add(String serverUrl, BitbucketApi api) { - instance().mocks.put(StringUtils.defaultString(serverUrl, NULL), api); + instance().mocks.put(Objects.toString(serverUrl, NULL), api); } public static void remove(String serverUrl) { - instance().mocks.remove(StringUtils.defaultString(serverUrl, NULL)); + instance().mocks.remove(Objects.toString(serverUrl, NULL)); } private static BitbucketMockApiFactory instance() { @@ -59,13 +59,13 @@ private static BitbucketMockApiFactory instance() { @Override protected boolean isMatch(@Nullable String serverUrl) { - return mocks.containsKey(StringUtils.defaultString(serverUrl, NULL)); + return mocks.containsKey(Objects.toString(serverUrl, NULL)); } @NonNull @Override protected BitbucketApi create(@Nullable String serverUrl, @Nullable BitbucketAuthenticator authenticator, @NonNull String owner, @CheckForNull String projectKey, @CheckForNull String repository) { - return mocks.get(StringUtils.defaultString(serverUrl, NULL)); + return mocks.get(Objects.toString(serverUrl, NULL)); } } diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketCloudApiClientTest.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketCloudApiClientTest.java index c31d62e0d..4d9f8fee5 100644 --- a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketCloudApiClientTest.java +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketCloudApiClientTest.java @@ -28,12 +28,11 @@ import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketBuildStatus; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketBuildStatus.Status; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketWebHook; -import com.cloudbees.jenkins.plugins.bitbucket.client.BitbucketIntegrationClientFactory.IAuditable; import com.cloudbees.jenkins.plugins.bitbucket.client.repository.BitbucketCloudRepository; import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketCloudEndpoint; import com.cloudbees.jenkins.plugins.bitbucket.impl.util.DateUtils; import com.cloudbees.jenkins.plugins.bitbucket.impl.util.JsonParser; -import edu.umd.cs.findbugs.annotations.NonNull; +import com.cloudbees.jenkins.plugins.bitbucket.test.util.BitbucketTestUtil; import hudson.ProxyConfiguration; import java.io.IOException; import java.io.InputStream; @@ -48,13 +47,11 @@ import org.junit.jupiter.api.Test; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.junit.jupiter.WithJenkins; -import org.mockito.ArgumentCaptor; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.reset; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; @@ -90,7 +87,7 @@ void verify_status_notitication_name_max_length() throws Exception { client.postBuildStatus(status); - HttpRequest request = extractRequest(client); + HttpRequest request = BitbucketTestUtil.extractRequest(client); assertThat(request).isNotNull() .isInstanceOf(HttpPost.class); try (InputStream content = ((HttpPost) request).getEntity().getContent()) { @@ -99,18 +96,6 @@ void verify_status_notitication_name_max_length() throws Exception { } } - private HttpRequest extractRequest(BitbucketApi client) { - assertThat(client).isInstanceOf(IAuditable.class); - - ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); - verify(((IAuditable) client).getAudit()).request(captor.capture()); - return captor.getValue(); - } - - private void resetAudit(@NonNull BitbucketApi client) { - reset(((IAuditable) client).getAudit()); - } - @Test void get_repository_parse_correctly_date_from_cloud() throws Exception { BitbucketCloudRepository repository = JsonParser.toJava(loadPayload("getRepository"), BitbucketCloudRepository.class); @@ -135,9 +120,8 @@ void verifyUpdateWebhookURL() throws Exception { .findFirst(); assertThat(webHook).isPresent(); - resetAudit(client); client.updateCommitWebHook(webHook.get()); - HttpRequest request = extractRequest(client); + HttpRequest request = BitbucketTestUtil.extractRequest(client); assertThat(request).isNotNull() .isInstanceOfSatisfying(HttpPut.class, put -> assertThat(put.getRequestUri()).isEqualTo("https://api.bitbucket.org/2.0/repositories/amuniz/test-repos/hooks/%7B202cf34e-7ccf-44b7-ba6b-8827a14d5324%7D")); diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketIntegrationClientFactory.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketIntegrationClientFactory.java index 9acc96ec5..aa2db9adc 100644 --- a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketIntegrationClientFactory.java +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/client/BitbucketIntegrationClientFactory.java @@ -32,6 +32,9 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.Stack; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; import org.apache.commons.io.IOUtils; import org.apache.hc.client5.http.classic.methods.HttpUriRequest; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; @@ -74,11 +77,51 @@ default ClassicHttpResponse loadResponseFromResources(Class resourceBase, Str } public interface IRequestAudit { - default void request(HttpRequest request) { - // mockito audit + void registerRequest(HttpRequest request); + + HttpRequest lastRequest(); + + CompletableFuture waitRequest(Predicate predicate); + + CompletableFuture waitRequest(); + } + + private static class RequestAudit implements IRequestAudit { + private CompletableFuture waitingRequestTask = null; + private Stack requests = new Stack<>(); + private Predicate requestPredicate = req -> true; + + @Override + public void registerRequest(HttpRequest request) { + requests.push(request); + if (waitingRequestTask != null && requestPredicate != null && requestPredicate.test(request)) { + waitingRequestTask.complete(request); + } + } + + @Override + public CompletableFuture waitRequest(Predicate predicate) { + if (waitingRequestTask != null) { + throw new IllegalStateException("There is already someone waiting for a request"); + } + this.requestPredicate = predicate; + return waitRequest(); + } + + @Override + public CompletableFuture waitRequest() { + if (waitingRequestTask != null) { + throw new IllegalStateException("There is already someone waiting for a request"); + } + waitingRequestTask = new CompletableFuture(); + return waitingRequestTask; + } + + @Override + public HttpRequest lastRequest() { + return requests.pop(); } - IRequestAudit getAudit(); } public static BitbucketApi getClient(String payloadRootPath, String serverURL, String owner, String repositoryName) { @@ -109,13 +152,13 @@ private BitbucketServerIntegrationClient(String payloadRootPath, String baseURL, } else { this.payloadRootPath = payloadRootPath; } - this.audit = mock(IRequestAudit.class); + this.audit = new RequestAudit(); } @Override protected ClassicHttpResponse executeMethod(HttpUriRequest httpMethod) throws IOException { String requestURI = httpMethod.getRequestUri(); - audit.request(httpMethod); + audit.registerRequest(httpMethod); String payloadPath = requestURI.substring(requestURI.indexOf("/rest/")) .replace("/rest/api/", "") @@ -158,7 +201,7 @@ private BitbucketClouldIntegrationClient(String payloadRootPath, String owner, S } else { this.payloadRootPath = payloadRootPath; } - this.audit = mock(IRequestAudit.class); + this.audit = new RequestAudit(); } @Override @@ -168,9 +211,9 @@ protected CloseableHttpClient getClient() { when(client.executeOpen(any(HttpHost.class), any(ClassicHttpRequest.class), any(HttpContext.class))).thenAnswer(new Answer() { @Override public ClassicHttpResponse answer(InvocationOnMock invocation) throws Throwable { - HttpRequest httpMethod = invocation.getArgument(1); - String uri = httpMethod.getRequestUri(); - audit.request(httpMethod); + HttpRequest request = invocation.getArgument(1); + String uri = request.getRequestUri(); + audit.registerRequest(request); String path = uri.replace(API_ENDPOINT, ""); if (path.startsWith("/")) { diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpointDescriptorTest.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpointDescriptorTest.java index 58b42bd09..67c0b9eb2 100644 --- a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpointDescriptorTest.java +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/endpoints/AbstractBitbucketEndpointDescriptorTest.java @@ -33,9 +33,11 @@ import com.damnhandy.uri.template.UriTemplate; import edu.umd.cs.findbugs.annotations.NonNull; import hudson.util.ListBoxModel; +import hudson.util.Secret; import java.util.Collections; import java.util.List; import org.hamcrest.Matchers; +import org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl; import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; @@ -63,7 +65,7 @@ public void given__cloudCredentials__when__listingForServer__then__noCredentials Collections.singletonList(new UsernamePasswordCredentialsImpl( CredentialsScope.SYSTEM, "dummy", "dummy", "user", "pass")))); ListBoxModel result = - new Dummy(true, "dummy").getDescriptor().doFillCredentialsIdItems("http://bitbucket.example.com"); + new Dummy(true, "dummy").getDescriptor().doFillCredentialsIdItems(null, "http://bitbucket.example.com"); assertThat(result, Matchers.hasSize(0)); } @@ -75,7 +77,19 @@ public void given__cloudCredentials__when__listingForCloud__then__credentials() Collections.singletonList(new UsernamePasswordCredentialsImpl( CredentialsScope.SYSTEM, "dummy", "dummy", "user", "pass")))); ListBoxModel result = - new Dummy(true, "dummy").getDescriptor().doFillCredentialsIdItems("http://bitbucket.org"); + new Dummy(true, "dummy").getDescriptor().doFillCredentialsIdItems(null, "http://bitbucket.org"); + assertThat(result, Matchers.hasSize(1)); + } + + @Test + public void given__cloud_HMAC_Credentials__when__listingForCloud__then__credentials() { + List domainSpecifications = Collections.singletonList(new HostnameSpecification("bitbucket.org", "")); + SystemCredentialsProvider.getInstance() + .setDomainCredentialsMap(Collections.singletonMap(new Domain("cloud", "bb cloud", domainSpecifications), + Collections.singletonList(new StringCredentialsImpl(CredentialsScope.SYSTEM, "dummy", "dummy", Secret.fromString("pass"))))); + ListBoxModel result = new Dummy(true, "dummy") + .getDescriptor() + .doFillHookSignatureCredentialsIdItems(null, "https://bitbucket.org"); assertThat(result, Matchers.hasSize(1)); } diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiverTest.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiverTest.java index 6d57c8322..6e65173d6 100644 --- a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiverTest.java +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/BitbucketSCMSourcePushHookReceiverTest.java @@ -23,91 +23,211 @@ */ package com.cloudbees.jenkins.plugins.bitbucket.hooks; +import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketCloudEndpoint; +import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketEndpointConfiguration; +import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketServerEndpoint; +import com.cloudbees.jenkins.plugins.bitbucket.test.util.BitbucketTestUtil; import jakarta.servlet.ReadListener; import jakarta.servlet.ServletInputStream; import java.io.IOException; import java.io.InputStream; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; import org.kohsuke.stapler.StaplerRequest2; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@WithJenkins class BitbucketSCMSourcePushHookReceiverTest { + private static JenkinsRule j; + + @BeforeAll + static void init(JenkinsRule rule) { + j = rule; + } + private BitbucketSCMSourcePushHookReceiver sut; private StaplerRequest2 req; private HookProcessor hookProcessor; + private String credentialsId; @BeforeEach - void setup() { + void setup() throws Exception { req = mock(StaplerRequest2.class); when(req.getRemoteHost()).thenReturn("https://bitbucket.org"); when(req.getParameter("server_url")).thenReturn("https://bitbucket.org"); - when(req.getRemoteAddr()).thenReturn("[185.166.143.48"); + when(req.getRemoteAddr()).thenReturn("185.166.143.48"); when(req.getScheme()).thenReturn("https"); when(req.getServerName()).thenReturn("jenkins.example.com"); when(req.getLocalPort()).thenReturn(80); when(req.getRequestURI()).thenReturn("/bitbucket-scmsource-hook/notify"); + when(req.getHeader("User-Agent")).thenReturn("Bitbucket-Webhooks/2.0"); + when(req.getHeader("X-Attempt-Number")).thenReturn("1"); + when(req.getHeader("Content-Type")).thenReturn("application/json"); + when(req.getHeader("X-Hook-UUID")).thenReturn(UUID.randomUUID().toString()); + when(req.getHeader("X-Request-UUID")).thenReturn(UUID.randomUUID().toString()); - sut = spy(new BitbucketSCMSourcePushHookReceiver()); hookProcessor = mock(HookProcessor.class); - doReturn(hookProcessor).when(sut).getHookProcessor(any(HookEventType.class)); + sut = new BitbucketSCMSourcePushHookReceiver() { + @Override + HookProcessor getHookProcessor(HookEventType type) { + return hookProcessor; + } + }; + + credentialsId = BitbucketTestUtil.registerHookCredentials("Gkvl$k$wyNpQAF42", j).getId(); + } + + @Test + void test_signature_is_missing_from_cloud_payload() throws Exception { + BitbucketCloudEndpoint endpoint = new BitbucketCloudEndpoint(false, null, BitbucketCloudEndpoint.SERVER_URL); + endpoint.setEnableHookSignature(true); + endpoint.setHookSignatureCredentialsId(credentialsId); + BitbucketEndpointConfiguration.get().updateEndpoint(endpoint); + + when(req.getHeader("X-Event-Key")).thenReturn("repo:push"); + when(req.getHeader("X-Bitbucket-Type")).thenReturn("cloud"); + when(req.getInputStream()).thenReturn(loadResource("cloud/signed_payload.json")); + + /*HttpResponse response = */sut.doNotify(req); + // really hard to verify if response contains a status 400 + verify(hookProcessor, never()).process(any(), anyString(), any(), anyString(), anyString()); + } + + @Test + void test_signature_from_cloud() throws Exception { + BitbucketCloudEndpoint endpoint = new BitbucketCloudEndpoint(false, null, BitbucketCloudEndpoint.SERVER_URL); + endpoint.setEnableHookSignature(true); + endpoint.setHookSignatureCredentialsId(credentialsId); + BitbucketEndpointConfiguration.get().updateEndpoint(endpoint); + + when(req.getHeader("X-Event-Key")).thenReturn("repo:push"); + when(req.getHeader("X-Bitbucket-Type")).thenReturn("cloud"); + when(req.getHeader("X-Hub-Signature")).thenReturn("sha256=f205c729821c6954aff2afe72b965c34015b4baf96ea8ddc2cc44999c014a035"); + when(req.getInputStream()).thenReturn(loadResource("cloud/signed_payload.json")); + + sut.doNotify(req); + verify(hookProcessor).process( + eq(HookEventType.PUSH), + anyString(), + eq(BitbucketType.CLOUD), + eq("https://bitbucket.org/185.166.143.48 ⇒ https://jenkins.example.com:80/bitbucket-scmsource-hook/notify"), + eq("https://bitbucket.org")); + } + + @Test + void test_bad_signature_from_cloud() throws Exception { + BitbucketCloudEndpoint endpoint = new BitbucketCloudEndpoint(false, null, BitbucketCloudEndpoint.SERVER_URL); + endpoint.setEnableHookSignature(true); + endpoint.setHookSignatureCredentialsId(credentialsId); + BitbucketEndpointConfiguration.get().updateEndpoint(endpoint); + + when(req.getHeader("X-Event-Key")).thenReturn("repo:push"); + when(req.getHeader("X-Bitbucket-Type")).thenReturn("cloud"); + when(req.getHeader("X-Hub-Signature")).thenReturn("sha256=f205c729821c6954aff2afe72b965c34015b4baf96ea8ddc2cc44999c014a036"); + when(req.getInputStream()).thenReturn(loadResource("cloud/signed_payload.json")); + + /*HttpResponse response = */sut.doNotify(req); + // really hard to verify if response contains a status 400 + verify(hookProcessor, never()).process(any(), anyString(), any(), anyString(), anyString()); + } + + @Test + void test_signature_from_native_server() throws Exception { + BitbucketServerEndpoint endpoint = new BitbucketServerEndpoint("datacenter", "http://localhost:7990/bitbucket", false, null, "https://jenkins.example.com"); + endpoint.setEnableHookSignature(true); + endpoint.setHookSignatureCredentialsId(credentialsId); + BitbucketEndpointConfiguration.get().updateEndpoint(endpoint); + + when(req.getRemoteHost()).thenReturn("http://localhost:7990"); + when(req.getParameter("server_url")).thenReturn(endpoint.getServerUrl()); + when(req.getHeader("X-Event-Key")).thenReturn("repo:refs_changed"); + when(req.getHeader("X-Request-Id")).thenReturn("2b15f131-4d3a-436e-bc63-caa9ae92580d"); + when(req.getHeader("X-Hub-Signature")).thenReturn("sha256=4ffba9e7b58ea3d7e1a230446e8c92baea0aeec89b73f598932387254f0de13e"); + when(req.getInputStream()).thenReturn(loadResource("native/signed_payload.json")); + + sut.doNotify(req); + // really hard to verify if response contains a status 400 + verify(hookProcessor).process( + eq(HookEventType.SERVER_REFS_CHANGED), + anyString(), + eq(BitbucketType.SERVER), + eq("http://localhost:7990/185.166.143.48 ⇒ https://jenkins.example.com:80/bitbucket-scmsource-hook/notify"), + eq(endpoint.getServerUrl())); + + // verify bad signature + reset(hookProcessor); + when(req.getHeader("X-Hub-Signature")).thenReturn("sha256=4ffba9e7b58ea3d7e1a230446e8c92baea0aeec89b73f598932387254f0de13f"); + when(req.getInputStream()).thenReturn(loadResource("native/signed_payload.json")); + /*HttpResponse response = */ sut.doNotify(req); + // really hard to verify if response contains a status 400 + verify(hookProcessor, never()).process(any(), anyString(), any(), anyString(), anyString()); + } + + @Test + void test_bad_signature_from_native_server() throws Exception { + BitbucketServerEndpoint endpoint = new BitbucketServerEndpoint("datacenter", "http://localhost:7990/bitbucket", false, null, "https://jenkins.example.com"); + endpoint.setEnableHookSignature(true); + endpoint.setHookSignatureCredentialsId(credentialsId); + BitbucketEndpointConfiguration.get().updateEndpoint(endpoint); + + when(req.getRemoteHost()).thenReturn("http://localhost:7990"); + when(req.getParameter("server_url")).thenReturn(endpoint.getServerUrl()); + when(req.getHeader("X-Event-Key")).thenReturn("repo:refs_changed"); + when(req.getHeader("X-Request-Id")).thenReturn("2b15f131-4d3a-436e-bc63-caa9ae92580d"); + when(req.getHeader("X-Hub-Signature")).thenReturn("sha256=4ffba9e7b58ea3d7e1a230446e8c92baea0aeec89b73f598932387254f0de13f"); + when(req.getInputStream()).thenReturn(loadResource("native/signed_payload.json")); + /*HttpResponse response = */ sut.doNotify(req); + // really hard to verify if response contains a status 400 + verify(hookProcessor, never()).process(any(), anyString(), any(), anyString(), anyString()); } @Test void test_pullrequest_created() throws Exception { when(req.getHeader("X-Event-Key")).thenReturn("pullrequest:created"); - when(req.getHeader("X-Hook-UUID")).thenReturn("fc2f2c82-c7de-485b-917b-550c576751d3"); - when(req.getHeader("User-Agent")).thenReturn("Bitbucket-Webhooks/2.0"); - when(req.getHeader("X-Attempt-Number")).thenReturn("1"); - when(req.getHeader("X-Request-UUID")).thenReturn("5deabc5d-2369-4e11-a86a-396de804feca"); - when(req.getHeader("Content-Type")).thenReturn("application/json"); when(req.getHeader("X-Bitbucket-Type")).thenReturn("cloud"); - when(req.getInputStream()).thenReturn(loadResource("pullrequest_created.json")); + when(req.getInputStream()).thenReturn(loadResource("cloud/pullrequest_created.json")); sut.doNotify(req); - verify(sut).getHookProcessor(HookEventType.PULL_REQUEST_CREATED); verify(hookProcessor).process( eq(HookEventType.PULL_REQUEST_CREATED), anyString(), eq(BitbucketType.CLOUD), - eq("https://bitbucket.org/[185.166.143.48 ⇒ https://jenkins.example.com:80/bitbucket-scmsource-hook/notify"), + eq("https://bitbucket.org/185.166.143.48 ⇒ https://jenkins.example.com:80/bitbucket-scmsource-hook/notify"), eq("https://bitbucket.org")); } @Test void test_pullrequest_declined() throws Exception { when(req.getHeader("X-Event-Key")).thenReturn("pullrequest:rejected"); - when(req.getHeader("X-Hook-UUID")).thenReturn("fc2f2c82-c7de-485b-917b-450c576751d7"); - when(req.getHeader("User-Agent")).thenReturn("Bitbucket-Webhooks/2.0"); - when(req.getHeader("X-Attempt-Number")).thenReturn("1"); - when(req.getHeader("X-Request-UUID")).thenReturn("2b600570-77fa-4476-a091-a22b501a5542"); - when(req.getHeader("Content-Type")).thenReturn("application/json"); when(req.getHeader("X-Bitbucket-Type")).thenReturn("cloud"); - when(req.getInputStream()).thenReturn(loadResource("pullrequest_rejected.json")); + when(req.getInputStream()).thenReturn(loadResource("cloud/pullrequest_rejected.json")); sut.doNotify(req); - verify(sut).getHookProcessor(HookEventType.PULL_REQUEST_DECLINED); verify(hookProcessor).process( eq(HookEventType.PULL_REQUEST_DECLINED), anyString(), eq(BitbucketType.CLOUD), - eq("https://bitbucket.org/[185.166.143.48 ⇒ https://jenkins.example.com:80/bitbucket-scmsource-hook/notify"), + eq("https://bitbucket.org/185.166.143.48 ⇒ https://jenkins.example.com:80/bitbucket-scmsource-hook/notify"), eq("https://bitbucket.org")); } private ServletInputStream loadResource(String resource) { - final InputStream delegate = this.getClass().getResourceAsStream("cloud/" + resource); + final InputStream delegate = this.getClass().getResourceAsStream(resource); return new ServletInputStream() { @Override diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookAutoRegisterListenerTest.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookAutoRegisterListenerTest.java new file mode 100644 index 000000000..6a4512c6c --- /dev/null +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/hooks/WebhookAutoRegisterListenerTest.java @@ -0,0 +1,134 @@ +/* + * The MIT License + * + * Copyright (c) 2025, Nikolas Falco + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.cloudbees.jenkins.plugins.bitbucket.hooks; + +import com.cloudbees.jenkins.plugins.bitbucket.BitbucketMockApiFactory; +import com.cloudbees.jenkins.plugins.bitbucket.BitbucketSCMSource; +import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketApi; +import com.cloudbees.jenkins.plugins.bitbucket.client.BitbucketIntegrationClientFactory; +import com.cloudbees.jenkins.plugins.bitbucket.endpoints.AbstractBitbucketEndpoint; +import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketEndpointConfiguration; +import com.cloudbees.jenkins.plugins.bitbucket.endpoints.BitbucketServerEndpoint; +import com.cloudbees.jenkins.plugins.bitbucket.impl.util.JsonParser; +import com.cloudbees.jenkins.plugins.bitbucket.server.client.repository.NativeBitbucketServerWebhook; +import com.cloudbees.jenkins.plugins.bitbucket.test.util.BitbucketTestUtil; +import hudson.model.TaskListener; +import java.io.IOException; +import java.util.List; +import jenkins.scm.api.SCMSource; +import jenkins.scm.api.SCMSourceCriteria; +import jenkins.scm.api.SCMSourceOwner; +import org.apache.hc.client5.http.classic.methods.HttpPut; +import org.apache.hc.core5.http.HttpRequest; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.jenkinsci.plugins.plaincredentials.StringCredentials; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@WithJenkins +class WebhookAutoRegisterListenerTest { + + static JenkinsRule rule = new JenkinsRule(); + + @BeforeAll + static void init(JenkinsRule r) { + rule = r; + } + + private WebhookAutoRegisterListener sut; + + @BeforeEach + void setup() { + sut = new WebhookAutoRegisterListener(); + } + + @Timeout(60) + @Test + void test_register() throws Exception { + String serverURL = "http://localhost:7990/bitbucket"; + BitbucketApi client = BitbucketIntegrationClientFactory.getClient(null, serverURL , "amuniz", "test-repos"); + BitbucketMockApiFactory.add(serverURL, client); + + StringCredentials credentials = BitbucketTestUtil.registerHookCredentials("password", rule); + + AbstractBitbucketEndpoint endpoint = new BitbucketServerEndpoint("datacenter", serverURL, true, "dummyId"); + endpoint.setEnableHookSignature(true); + endpoint.setHookSignatureCredentialsId(credentials.getId()); + BitbucketEndpointConfiguration.get().updateEndpoint(endpoint); + + BitbucketSCMSource scmSource = new BitbucketSCMSource("amuniz", "test-repos") { + @Override + public String getEndpointJenkinsRootURL() { + return "https://jenkins.example.com/"; + } + }; + scmSource.setServerUrl(serverURL); + scmSource.setOwner(getSCMSourceOwnerMock(scmSource)); + + sut.onCreated(scmSource.getOwner()); + HttpRequest request = BitbucketTestUtil.waitForRequest(client, req -> { + return "PUT".equals(req.getMethod()) && req.getPath().contains("webhooks"); + }).get(); + assertThat(request).isNotNull() + .isInstanceOf(HttpPut.class) + .asInstanceOf(InstanceOfAssertFactories.type(HttpPut.class)) + .satisfies(put -> { + NativeBitbucketServerWebhook message = JsonParser.toJava(put.getEntity().getContent(), NativeBitbucketServerWebhook.class); + assertThat(message.getSecret()).isEqualTo("password"); + }); + } + + @SuppressWarnings("serial") + private SCMSourceOwner getSCMSourceOwnerMock(SCMSource scmSource) { + SCMSourceOwner scmSourceOwner = mock(SCMSourceOwner.class); + when(scmSourceOwner.getSCMSources()).thenReturn(List.of(scmSource)); + when(scmSourceOwner.getSCMSourceCriteria(any(SCMSource.class))).thenReturn(new SCMSourceCriteria() { + + @Override + public boolean isHead(Probe probe, TaskListener listener) throws IOException { + return probe.stat("markerfile.txt").exists(); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + @Override + public boolean equals(Object obj) { + return getClass().isInstance(obj); + } + }); + return scmSourceOwner; + } +} diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClientTest.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClientTest.java index dc636bbe8..bdf5515a3 100644 --- a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClientTest.java +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/server/client/BitbucketServerAPIClientTest.java @@ -30,9 +30,8 @@ import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketRepository; import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketTeam; import com.cloudbees.jenkins.plugins.bitbucket.client.BitbucketIntegrationClientFactory; -import com.cloudbees.jenkins.plugins.bitbucket.client.BitbucketIntegrationClientFactory.IAuditable; -import com.cloudbees.jenkins.plugins.bitbucket.client.BitbucketIntegrationClientFactory.IRequestAudit; import com.cloudbees.jenkins.plugins.bitbucket.server.BitbucketServerWebhookImplementation; +import com.cloudbees.jenkins.plugins.bitbucket.test.util.BitbucketTestUtil; import hudson.ProxyConfiguration; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -54,7 +53,6 @@ import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.junit.jupiter.WithJenkins; -import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; @@ -92,7 +90,7 @@ void verify_status_notitication_name_max_length() throws Exception { client.postBuildStatus(status); - HttpRequest request = extractRequest(client); + HttpRequest request = BitbucketTestUtil.extractRequest(client); assertThat(request).isNotNull() .isInstanceOf(HttpPost.class); try (InputStream content = ((HttpPost) request).getEntity().getContent()) { @@ -113,7 +111,7 @@ void verify_status_notitication_key_max_length() throws Exception { client.postBuildStatus(status); - HttpRequest request = extractRequest(client); + HttpRequest request = BitbucketTestUtil.extractRequest(client); assertThat(request).isNotNull().isInstanceOf(HttpPost.class); try (InputStream content = ((HttpPost) request).getEntity().getContent()) { String json = IOUtils.toString(content, StandardCharsets.UTF_8); @@ -125,21 +123,12 @@ void verify_status_notitication_key_max_length() throws Exception { } } - private HttpRequest extractRequest(BitbucketApi client) { - assertThat(client).isInstanceOf(IAuditable.class); - IRequestAudit clientAudit = ((IAuditable) client).getAudit(); - - ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); - verify(clientAudit).request(captor.capture()); - return captor.getValue(); - } - @Test void verify_checkPathExists_given_a_path() throws Exception { BitbucketApi client = BitbucketIntegrationClientFactory.getApiMockClient("https://acme.bitbucket.org"); assertThat(client.checkPathExists("feature/pipeline", "folder/Jenkinsfile")).isTrue(); - HttpRequest request = extractRequest(client); + HttpRequest request = BitbucketTestUtil.extractRequest(client); assertThat(request).isNotNull() .isInstanceOf(HttpHead.class) .asInstanceOf(InstanceOfAssertFactories.type(HttpHead.class)) @@ -157,7 +146,7 @@ void verify_checkPathExists_given_file() throws Exception { BitbucketApi client = BitbucketIntegrationClientFactory.getApiMockClient("https://acme.bitbucket.org"); assertThat(client.checkPathExists("feature/pipeline", "Jenkinsfile")).isTrue(); - HttpRequest request = extractRequest(client); + HttpRequest request = BitbucketTestUtil.extractRequest(client); assertThat(request).isNotNull() .isInstanceOf(HttpHead.class) .asInstanceOf(InstanceOfAssertFactories.type(HttpHead.class)) @@ -213,7 +202,7 @@ void verify_getBranch_request_URL() throws Exception { BitbucketServerAPIClient client = (BitbucketServerAPIClient) BitbucketIntegrationClientFactory.getClient(serverURL, "amuniz", "test-repos"); client.getBranch("feature/BB-1"); - HttpRequest request = extractRequest(client); + HttpRequest request = BitbucketTestUtil.extractRequest(client); assertThat(request).isNotNull() .isInstanceOf(HttpGet.class) .asInstanceOf(InstanceOfAssertFactories.type(HttpGet.class)) @@ -231,7 +220,7 @@ void verify_getTag_request_URL() throws Exception { BitbucketServerAPIClient client = (BitbucketServerAPIClient) BitbucketIntegrationClientFactory.getClient(serverURL, "amuniz", "test-repos"); client.getTag("v0.0.0"); - HttpRequest request = extractRequest(client); + HttpRequest request = BitbucketTestUtil.extractRequest(client); assertThat(request).isNotNull() .isInstanceOf(HttpGet.class) .asInstanceOf(InstanceOfAssertFactories.type(HttpGet.class)) diff --git a/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/test/util/BitbucketTestUtil.java b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/test/util/BitbucketTestUtil.java new file mode 100644 index 000000000..176c85822 --- /dev/null +++ b/src/test/java/com/cloudbees/jenkins/plugins/bitbucket/test/util/BitbucketTestUtil.java @@ -0,0 +1,67 @@ +/* + * The MIT License + * + * Copyright (c) 2025, Nikolas Falco + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package com.cloudbees.jenkins.plugins.bitbucket.test.util; + +import com.cloudbees.jenkins.plugins.bitbucket.api.BitbucketApi; +import com.cloudbees.jenkins.plugins.bitbucket.client.BitbucketIntegrationClientFactory.IAuditable; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.domains.Domain; +import hudson.util.Secret; +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; +import org.apache.hc.core5.http.HttpRequest; +import org.jenkinsci.plugins.plaincredentials.StringCredentials; +import org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl; +import org.jvnet.hudson.test.JenkinsRule; + +import static org.assertj.core.api.Assertions.assertThat; + +public final class BitbucketTestUtil { + + private BitbucketTestUtil() { + } + + public static StringCredentials registerHookCredentials(String secret, JenkinsRule rule) throws IOException { + StringCredentials secretCredentials = new StringCredentialsImpl(CredentialsScope.GLOBAL, "hmac", null, Secret.fromString(secret)); + CredentialsProvider.lookupStores(rule.jenkins) + .iterator() + .next() + .addCredentials(Domain.global(), secretCredentials); + return secretCredentials; + } + + public static HttpRequest extractRequest(BitbucketApi client) { + assertThat(client).isInstanceOf(IAuditable.class); + + return ((IAuditable) client).getAudit().lastRequest(); + } + + public static CompletableFuture waitForRequest(BitbucketApi client, Predicate predicate) { + assertThat(client).isInstanceOf(IAuditable.class); + + return ((IAuditable) client).getAudit().waitRequest(predicate); + } +} diff --git a/src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/hooks/cloud/signed_payload.json b/src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/hooks/cloud/signed_payload.json new file mode 100644 index 000000000..818bf6189 --- /dev/null +++ b/src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/hooks/cloud/signed_payload.json @@ -0,0 +1 @@ +{"push":{"changes":[{"old":null,"new":{"name":"feature/webhook","target":{"type":"commit","hash":"d7f300a32270053bdfd97a6d64113f58202c33e7","date":"2025-05-16T07:36:56+00:00","author":{"type":"author","raw":"Nikolas Falco ","user":{"display_name":"Nikolas Falco","links":{"self":{"href":"https://api.bitbucket.org/2.0/users/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D"},"avatar":{"href":"https://secure.gravatar.com/avatar/9979052fd773fbc9c0d94be07bbc8b5d?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FNF-3.png"},"html":{"href":"https://bitbucket.org/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D/"}},"type":"user","uuid":"{7d3a178a-a087-4756-b2da-2f9eadf50ba8}","account_id":"557058:270a1f96-cd27-4013-ade6-85df2ab9820c","nickname":"Nikolas Falco"}},"committer":{},"message":"Test for webhook signature\n","summary":{"type":"rendered","raw":"Test for webhook signature\n","markup":"markdown","html":"

Test for webhook signature

"},"links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/d7f300a32270053bdfd97a6d64113f58202c33e7"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/d7f300a32270053bdfd97a6d64113f58202c33e7"}},"parents":[{"hash":"174561d625c9623b60d8aba09b7f08ddc9df45cd","links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/174561d625c9623b60d8aba09b7f08ddc9df45cd"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/174561d625c9623b60d8aba09b7f08ddc9df45cd"}},"type":"commit"}],"rendered":{},"properties":{}},"links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/refs/branches/feature/webhook"},"commits":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commits/feature/webhook"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/branch/feature/webhook"},"pullrequest_create":{"href":"https://bitbucket.org/nfalco79/test-repos/pull-requests/new?source=feature/webhook&t=1"}},"type":"branch","merge_strategies":["merge_commit","squash","fast_forward","squash_fast_forward","rebase_fast_forward","rebase_merge"],"sync_strategies":["merge_commit","rebase"],"default_merge_strategy":"merge_commit"},"truncated":false,"created":true,"forced":false,"closed":false,"links":{"commits":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commits?include=d7f300a32270053bdfd97a6d64113f58202c33e7"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/branch/feature/webhook"}},"commits":[{"type":"commit","hash":"d7f300a32270053bdfd97a6d64113f58202c33e7","date":"2025-05-16T07:36:56+00:00","author":{"type":"author","raw":"Nikolas Falco ","user":{"display_name":"Nikolas Falco","links":{"self":{"href":"https://api.bitbucket.org/2.0/users/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D"},"avatar":{"href":"https://secure.gravatar.com/avatar/9979052fd773fbc9c0d94be07bbc8b5d?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FNF-3.png"},"html":{"href":"https://bitbucket.org/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D/"}},"type":"user","uuid":"{7d3a178a-a087-4756-b2da-2f9eadf50ba8}","account_id":"557058:270a1f96-cd27-4013-ade6-85df2ab9820c","nickname":"Nikolas Falco"}},"committer":{},"message":"Test for webhook signature\n","summary":{"type":"rendered","raw":"Test for webhook signature\n","markup":"markdown","html":"

Test for webhook signature

"},"links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/d7f300a32270053bdfd97a6d64113f58202c33e7"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/d7f300a32270053bdfd97a6d64113f58202c33e7"},"diff":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/diff/d7f300a32270053bdfd97a6d64113f58202c33e7"},"approve":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/d7f300a32270053bdfd97a6d64113f58202c33e7/approve"},"comments":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/d7f300a32270053bdfd97a6d64113f58202c33e7/comments"},"statuses":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/d7f300a32270053bdfd97a6d64113f58202c33e7/statuses"},"patch":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/patch/d7f300a32270053bdfd97a6d64113f58202c33e7"}},"parents":[{"hash":"174561d625c9623b60d8aba09b7f08ddc9df45cd","links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/174561d625c9623b60d8aba09b7f08ddc9df45cd"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/174561d625c9623b60d8aba09b7f08ddc9df45cd"}},"type":"commit"}],"rendered":{},"properties":{}},{"type":"commit","hash":"174561d625c9623b60d8aba09b7f08ddc9df45cd","date":"2025-04-26T09:43:26+00:00","author":{"type":"author","raw":"Nikolas Falco ","user":{"display_name":"Nikolas Falco","links":{"self":{"href":"https://api.bitbucket.org/2.0/users/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D"},"avatar":{"href":"https://secure.gravatar.com/avatar/9979052fd773fbc9c0d94be07bbc8b5d?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FNF-3.png"},"html":{"href":"https://bitbucket.org/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D/"}},"type":"user","uuid":"{7d3a178a-a087-4756-b2da-2f9eadf50ba8}","account_id":"557058:270a1f96-cd27-4013-ade6-85df2ab9820c","nickname":"Nikolas Falco"}},"committer":{},"message":"Add simple pipeline\n","summary":{"type":"rendered","raw":"Add simple pipeline\n","markup":"markdown","html":"

Add simple pipeline

"},"links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/174561d625c9623b60d8aba09b7f08ddc9df45cd"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/174561d625c9623b60d8aba09b7f08ddc9df45cd"},"diff":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/diff/174561d625c9623b60d8aba09b7f08ddc9df45cd"},"approve":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/174561d625c9623b60d8aba09b7f08ddc9df45cd/approve"},"comments":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/174561d625c9623b60d8aba09b7f08ddc9df45cd/comments"},"statuses":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/174561d625c9623b60d8aba09b7f08ddc9df45cd/statuses"},"patch":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/patch/174561d625c9623b60d8aba09b7f08ddc9df45cd"}},"parents":[{"hash":"e43fdffe2def75053da30efa8204d0317a0b932a","links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/e43fdffe2def75053da30efa8204d0317a0b932a"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/e43fdffe2def75053da30efa8204d0317a0b932a"}},"type":"commit"}],"rendered":{},"properties":{}},{"type":"commit","hash":"e43fdffe2def75053da30efa8204d0317a0b932a","date":"2025-01-18T16:10:05+00:00","author":{"type":"author","raw":"Nikolas Falco ","user":{"display_name":"Nikolas Falco","links":{"self":{"href":"https://api.bitbucket.org/2.0/users/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D"},"avatar":{"href":"https://secure.gravatar.com/avatar/9979052fd773fbc9c0d94be07bbc8b5d?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FNF-3.png"},"html":{"href":"https://bitbucket.org/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D/"}},"type":"user","uuid":"{7d3a178a-a087-4756-b2da-2f9eadf50ba8}","account_id":"557058:270a1f96-cd27-4013-ade6-85df2ab9820c","nickname":"Nikolas Falco"}},"committer":{},"message":"Add resource file under a folder to test resource metadata\n","summary":{"type":"rendered","raw":"Add resource file under a folder to test resource metadata\n","markup":"markdown","html":"

Add resource file under a folder to test resource metadata

"},"links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/e43fdffe2def75053da30efa8204d0317a0b932a"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/e43fdffe2def75053da30efa8204d0317a0b932a"},"diff":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/diff/e43fdffe2def75053da30efa8204d0317a0b932a"},"approve":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/e43fdffe2def75053da30efa8204d0317a0b932a/approve"},"comments":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/e43fdffe2def75053da30efa8204d0317a0b932a/comments"},"statuses":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/e43fdffe2def75053da30efa8204d0317a0b932a/statuses"},"patch":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/patch/e43fdffe2def75053da30efa8204d0317a0b932a"}},"parents":[{"hash":"bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf","links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf"}},"type":"commit"}],"rendered":{},"properties":{}},{"type":"commit","hash":"bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf","date":"2018-09-21T14:07:25+00:00","author":{"type":"author","raw":"Antonio Muniz "},"committer":{},"message":"Add sample script hello world","summary":{"type":"rendered","raw":"Add sample script hello world","markup":"markdown","html":"

Add sample script hello world

"},"links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf"},"diff":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/diff/bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf"},"approve":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf/approve"},"comments":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf/comments"},"statuses":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf/statuses"},"patch":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/patch/bf4f4ce8a3a8d5c7dbfe7d609973a81a6c6664cf"}},"parents":[{"hash":"8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1","links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1"}},"type":"commit"}],"rendered":{},"properties":{}},{"type":"commit","hash":"8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1","date":"2018-09-21T14:04:55+00:00","author":{"type":"author","raw":"Antonio Muniz "},"committer":{},"message":"Initial commit","summary":{"type":"rendered","raw":"Initial commit","markup":"markdown","html":"

Initial commit

"},"links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos/commits/8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1"},"diff":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/diff/8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1"},"approve":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1/approve"},"comments":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1/comments"},"statuses":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/commit/8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1/statuses"},"patch":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos/patch/8d0fa145bde5151f1d103ab1c3dc1033e6ec4ac1"}},"parents":[],"rendered":{},"properties":{}}]}]},"repository":{"type":"repository","full_name":"nfalco79/test-repos","links":{"self":{"href":"https://api.bitbucket.org/2.0/repositories/nfalco79/test-repos"},"html":{"href":"https://bitbucket.org/nfalco79/test-repos"},"avatar":{"href":"https://bytebucket.org/ravatar/%7B3deb8c29-778a-450c-8f69-3e50a18079df%7D?ts=3693474"}},"name":"test-repos","scm":"git","website":null,"owner":{"display_name":"Nikolas Falco","links":{"self":{"href":"https://api.bitbucket.org/2.0/users/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D"},"avatar":{"href":"https://secure.gravatar.com/avatar/9979052fd773fbc9c0d94be07bbc8b5d?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FNF-3.png"},"html":{"href":"https://bitbucket.org/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D/"}},"type":"user","uuid":"{7d3a178a-a087-4756-b2da-2f9eadf50ba8}","account_id":"557058:270a1f96-cd27-4013-ade6-85df2ab9820c","nickname":"Nikolas Falco"},"workspace":{"type":"workspace","uuid":"{7d3a178a-a087-4756-b2da-2f9eadf50ba8}","name":"Nikolas Falco","slug":"nfalco79","links":{"avatar":{"href":"https://bitbucket.org/workspaces/nfalco79/avatar/?ts=1737924067"},"html":{"href":"https://bitbucket.org/nfalco79/"},"self":{"href":"https://api.bitbucket.org/2.0/workspaces/nfalco79"}}},"is_private":true,"project":{"type":"project","key":"PUB","uuid":"{ef731d07-06e0-46d2-9b56-2674649b0655}","name":"public","links":{"self":{"href":"https://api.bitbucket.org/2.0/workspaces/nfalco79/projects/PUB"},"html":{"href":"https://bitbucket.org/nfalco79/workspace/projects/PUB"},"avatar":{"href":"https://bitbucket.org/nfalco79/workspace/projects/PUB/avatar/32?ts=1738923688"}}},"uuid":"{3deb8c29-778a-450c-8f69-3e50a18079df}","parent":null},"actor":{"display_name":"Nikolas Falco","links":{"self":{"href":"https://api.bitbucket.org/2.0/users/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D"},"avatar":{"href":"https://secure.gravatar.com/avatar/9979052fd773fbc9c0d94be07bbc8b5d?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FNF-3.png"},"html":{"href":"https://bitbucket.org/%7B7d3a178a-a087-4756-b2da-2f9eadf50ba8%7D/"}},"type":"user","uuid":"{7d3a178a-a087-4756-b2da-2f9eadf50ba8}","account_id":"557058:270a1f96-cd27-4013-ade6-85df2ab9820c","nickname":"Nikolas Falco"}} \ No newline at end of file diff --git a/src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/hooks/native/signed_payload.json b/src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/hooks/native/signed_payload.json new file mode 100644 index 000000000..e65354bdb --- /dev/null +++ b/src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/hooks/native/signed_payload.json @@ -0,0 +1 @@ +{"eventKey":"repo:refs_changed","date":"2025-05-18T16:57:51+0000","actor":{"name":"admin","emailAddress":"admin@example.com","active":true,"displayName":"Administrator","id":2,"slug":"admin","type":"NORMAL","links":{"self":[{"href":"http://localhost:7990/bitbucket/users/admin"}]}},"repository":{"slug":"test-repos","id":2,"name":"test-repos","hierarchyId":"2dee9fd202ced3ee233d","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AMUNIZ","id":2,"name":"amuniz","public":false,"type":"NORMAL","links":{"self":[{"href":"http://localhost:7990/bitbucket/projects/AMUNIZ"}]}},"public":false,"archived":false,"links":{"clone":[{"href":"http://localhost:7990/bitbucket/scm/amuniz/test-repos.git","name":"http"},{"href":"ssh://git@localhost:7999/amuniz/test-repos.git","name":"ssh"}],"self":[{"href":"http://localhost:7990/bitbucket/projects/AMUNIZ/repos/test-repos/browse"}]}},"changes":[{"ref":{"id":"refs/heads/feature/BB-1","displayId":"feature/BB-1","type":"BRANCH"},"refId":"refs/heads/feature/BB-1","fromHash":"fb522a6f08c7c7df337312e4e65ec1b57710672e","toHash":"0000000000000000000000000000000000000000","type":"DELETE"}]} \ No newline at end of file diff --git a/src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/server/payload/1.0-projects-amuniz-repos-test-repos-webhooks_start_0_limit_200.json b/src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/server/payload/1.0-projects-amuniz-repos-test-repos-webhooks_start_0_limit_200.json new file mode 100644 index 000000000..6a458e2d8 --- /dev/null +++ b/src/test/resources/com/cloudbees/jenkins/plugins/bitbucket/server/payload/1.0-projects-amuniz-repos-test-repos-webhooks_start_0_limit_200.json @@ -0,0 +1,24 @@ +{ + "size": 1, + "limit": 25, + "isLastPage": true, + "values": [ + { + "id": 2, + "name": "Jenkins hooks", + "createdDate": 1747578077061, + "updatedDate": 1747578077061, + "events": [ + "repo:refs_changed" + ], + "configuration": { + "createdBy": "bitbucket" + }, + "url": "https://jenkins.example.com/bitbucket-scmsource-hook/notify/?server_url=http%3A%2F%2Flocalhost%3A7990%2Fbitbucket", + "active": true, + "scopeType": "repository", + "sslVerificationRequired": true + } + ], + "start": 0 +} \ No newline at end of file