diff --git a/README.adoc b/README.adoc index 3d50b59574..e74d8eee50 100644 --- a/README.adoc +++ b/README.adoc @@ -277,6 +277,32 @@ If the workspace is removed, the tag that was applied is lost. Tagging a workspace made sense when using centralized repositories that automatically applied the tag to the centralized repository. Applying a git tag in an agent workspace doesn't have many practical uses. +[#security-configuration] +=== Security Configuration + +image:/images/git-security-configuration.png[Security Configuration] + +In the `Configure Global Security` page, the Git Plugin provides the following option: + +[[global-security-git-hooks]] +Git Hooks:: + + link:https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks[Git hooks] allow scripts to be invoked when certain important git repository actions occur. + This configuration controls the execution of client-side hooks on the controller and on agents. + It is recommended that git hooks be **disabled** on the controller and on agents. ++ +Most git repositories do not use hooks in the repository and do not need repository hooks. +In those rare cases where repository hooks are needed, it is highly recommended that they are **disabled** on the Jenkins controller and on Jenkins agents. ++ +Client-side hooks are **not** copied when the repository is cloned by Jenkins using the inbuilt SCM methods. +However, client-side hooks might be installed in a repository by build steps or by misconfiguration. ++ +If hook scripts are allowed, a client-side hook script installed in a repository will execute when the matching git operation is performed. +For example, if hooks are allowed and a git repository includes a `post-checkout` hook, the hook script will run after any checkout in that repository. +If hooks are allowed and a git repository includes a `pre-auto-gc` hook, the hook script will run before any automatic git garbage collection task. ++ +See link:https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks["Customizing Git - Git Hooks"] for more details about git repository hooks. + [#repository-browser] === Repository Browser diff --git a/images/git-security-configuration.png b/images/git-security-configuration.png new file mode 100644 index 0000000000..711daaff46 Binary files /dev/null and b/images/git-security-configuration.png differ diff --git a/pom.xml b/pom.xml index c2c52794d4..9925de9f4c 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,7 @@ Max true Medium + 0.8C @@ -340,13 +341,6 @@ - - maven-surefire-plugin - - 0.8C - true - - diff --git a/src/main/java/hudson/plugins/git/GitSCM.java b/src/main/java/hudson/plugins/git/GitSCM.java index cfb6417d40..0b30787dc5 100644 --- a/src/main/java/hudson/plugins/git/GitSCM.java +++ b/src/main/java/hudson/plugins/git/GitSCM.java @@ -50,6 +50,7 @@ import hudson.util.FormValidation; import hudson.util.ListBoxModel; import jenkins.model.Jenkins; +import jenkins.plugins.git.GitHooksConfiguration; import jenkins.plugins.git.GitSCMMatrixUtil; import jenkins.plugins.git.GitToolChooser; import jenkins.util.SystemProperties; @@ -800,6 +801,7 @@ private PollingResult compareRemoteRevisionWithImpl(Job project, Launcher GitClient git = createClient(listener, environment, lastBuild, node, workingDirectory); if (git.hasGitRepo(false)) { + GitHooksConfiguration.configure(git); // Repo is there - do a fetch listener.getLogger().println("Fetching changes from the remote Git repositories"); @@ -1234,6 +1236,7 @@ private void retrieveChanges(Run build, GitClient git, TaskListener listener) th throw new AbortException("Error cloning remote repo '" + rc.getName() + "'"); } } + GitHooksConfiguration.configure(git); for (RemoteConfig remoteRepository : repos) { if (remoteRepository.equals(repos.get(0)) && removeSecondFetch){ diff --git a/src/main/java/jenkins/plugins/git/AbstractGitSCMSource.java b/src/main/java/jenkins/plugins/git/AbstractGitSCMSource.java index a9e51aa5d3..9178ff0438 100644 --- a/src/main/java/jenkins/plugins/git/AbstractGitSCMSource.java +++ b/src/main/java/jenkins/plugins/git/AbstractGitSCMSource.java @@ -374,6 +374,8 @@ private , R extends GitSCMSourceRequest> listener.getLogger().println("Creating git repository in " + cacheDir); client.init(); } + GitHooksConfiguration.configure(client, GitHooksConfiguration.get().isAllowedOnController()); + String remoteName = context.remoteName(); listener.getLogger().println("Setting " + remoteName + " to " + getRemote()); client.setRemoteUrl(remoteName, getRemote()); diff --git a/src/main/java/jenkins/plugins/git/GitHooksConfiguration.java b/src/main/java/jenkins/plugins/git/GitHooksConfiguration.java new file mode 100644 index 0000000000..f1ce3cc633 --- /dev/null +++ b/src/main/java/jenkins/plugins/git/GitHooksConfiguration.java @@ -0,0 +1,141 @@ +/* + * The MIT License + * + * Copyright (c) 2021 CloudBees, Inc. + * + * 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 jenkins.plugins.git; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.Functions; +import hudson.model.PersistentDescriptor; +import hudson.remoting.Channel; +import jenkins.model.GlobalConfiguration; +import jenkins.model.GlobalConfigurationCategory; +import org.apache.commons.lang.StringUtils; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.StoredConfig; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.gitclient.GitClient; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import java.io.IOException; +import java.util.logging.Logger; + + + +@Extension @Symbol("gitHooks") @Restricted(NoExternalUse.class) +public class GitHooksConfiguration extends GlobalConfiguration implements PersistentDescriptor { + + public static final String DISABLED_WIN = "NUL:"; + public static final String DISABLED_NIX = "/dev/null"; + static final Logger LOGGER = Logger.getLogger(GitHooksConfiguration.class.getName()); + + private boolean allowedOnController = false; + private boolean allowedOnAgents = false; + + @NonNull + public static GitHooksConfiguration get() { + final GitHooksConfiguration configuration = GlobalConfiguration.all().get(GitHooksConfiguration.class); + if (configuration == null) { + throw new IllegalStateException("[BUG] No configuration registered, make sure not running on an agent or that Jenkins has started properly."); + } + return configuration; + } + + public boolean isAllowedOnController() { + return allowedOnController; + } + + public void setAllowedOnController(final boolean allowedOnController) { + this.allowedOnController = allowedOnController; + save(); + } + + public boolean isAllowedOnAgents() { + return allowedOnAgents; + } + + public void setAllowedOnAgents(final boolean allowedOnAgents) { + this.allowedOnAgents = allowedOnAgents; + save(); + } + + @Override @NonNull + public GlobalConfigurationCategory getCategory() { + return GlobalConfigurationCategory.get(GlobalConfigurationCategory.Security.class); + } + + public static void configure(GitClient client) throws IOException, InterruptedException { + final GitHooksConfiguration configuration = GitHooksConfiguration.get(); + configure(client, configuration.isAllowedOnController(), configuration.isAllowedOnAgents()); + } + + public static void configure(GitClient client, final boolean allowedOnController, final boolean allowedOnAgents) throws IOException, InterruptedException { + if (Channel.current() == null) { + //Running on controller + try (Repository ignored = client.getRepository()){ + //That went well, so the code runs on the controller and the repo is local + configure(client, allowedOnController); + } catch (UnsupportedOperationException e) { + // Client represents a remote repository, so this code runs on the controller but the repo is on an agent + configure(client, allowedOnAgents); + } + } else { + //Running on agent + configure(client, allowedOnAgents); + } + } + + public static void configure(GitClient client, final boolean allowed) throws IOException, InterruptedException { + if (!allowed) { + client.withRepository((repo, channel) -> { + disable(repo); + return null; + }); + } else { + client.withRepository((repo, channel) -> { + unset(repo); + return null; + }); + } + } + + private static void unset(final Repository repo) throws IOException { + final StoredConfig repoConfig = repo.getConfig(); + final String val = repoConfig.getString("core", null, "hooksPath"); + if (!StringUtils.isEmpty(val) && !(DISABLED_NIX.equals(val) || DISABLED_WIN.equals(val))) { + LOGGER.warning(() -> String.format("core.hooksPath explicitly set to %s and will be left intact on %s.", val, repo.getDirectory())); + } else { + repoConfig.unset("core", null, "hooksPath"); + repoConfig.save(); + } + } + + private static void disable(final Repository repo) throws IOException { + final String VAL = Functions.isWindows() ? DISABLED_WIN : DISABLED_NIX; + final StoredConfig repoConfig = repo.getConfig(); + repoConfig.setString("core", null, "hooksPath", VAL); + repoConfig.save(); + } +} diff --git a/src/main/java/jenkins/plugins/git/GitSCMFileSystem.java b/src/main/java/jenkins/plugins/git/GitSCMFileSystem.java index 6e7847d271..e585139d0b 100644 --- a/src/main/java/jenkins/plugins/git/GitSCMFileSystem.java +++ b/src/main/java/jenkins/plugins/git/GitSCMFileSystem.java @@ -334,6 +334,7 @@ public SCMFileSystem build(@NonNull Item owner, @NonNull SCM scm, @CheckForNull listener.getLogger().println("Creating git repository in " + cacheDir); client.init(); } + GitHooksConfiguration.configure(client, GitHooksConfiguration.get().isAllowedOnController()); String remoteName = StringUtils.defaultIfBlank(config.getName(), Constants.DEFAULT_REMOTE_NAME); listener.getLogger().println("Setting " + remoteName + " to " + remote); client.setRemoteUrl(remoteName, remote); @@ -396,6 +397,7 @@ public SCMFileSystem build(@NonNull SCMSource source, @NonNull SCMHead head, @Ch listener.getLogger().println("Creating git repository in " + cacheDir); client.init(); } + GitHooksConfiguration.configure(client, GitHooksConfiguration.get().isAllowedOnController()); String remoteName = builder.remoteName(); listener.getLogger().println("Setting " + remoteName + " to " + gitSCMSource.getRemote()); client.setRemoteUrl(remoteName, gitSCMSource.getRemote()); diff --git a/src/main/resources/jenkins/plugins/git/GitHooksConfiguration/config.jelly b/src/main/resources/jenkins/plugins/git/GitHooksConfiguration/config.jelly new file mode 100644 index 0000000000..dea843a38a --- /dev/null +++ b/src/main/resources/jenkins/plugins/git/GitHooksConfiguration/config.jelly @@ -0,0 +1,36 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/jenkins/plugins/git/GitHooksConfiguration/help-allowedOnAgents.html b/src/main/resources/jenkins/plugins/git/GitHooksConfiguration/help-allowedOnAgents.html new file mode 100644 index 0000000000..c2dd80b4ce --- /dev/null +++ b/src/main/resources/jenkins/plugins/git/GitHooksConfiguration/help-allowedOnAgents.html @@ -0,0 +1,23 @@ +
+

+ Git hooks allow scripts to be invoked when certain important git repository actions occur. + This configuration controls the execution of client-side hooks on Jenkins agents. + It is recommended that git hooks be disabled on Jenkins agents. +

+

+ Most git repositories do not use hooks in the repository and do not need repository hooks. + In those rare cases where repository hooks are needed, it is highly recommended that they are disabled on the Jenkins controller and on Jenkins agents. +

+

+ Client-side hooks are not copied when the repository is cloned. + However, client-side hooks might be installed in a repository by build steps or by misconfiguration. +

+

+ If hook scripts are allowed on agents, a client-side hook script installed in a repository on a Jenkins agent will execute when the matching git operation is performed. + For example, if hooks are allowed on agents and a git repository on an agent includes a post-checkout hook, the hook script will run on the agent after any checkout in that repository. + If hooks are allowed on agents and a git repository on an agent includes a pre-auto-gc hook, the hook script will run on the agent before any automatic git garbage collection task. +

+

+ See "Customizing Git - Git Hooks" for more details about git repository hooks. +

+
diff --git a/src/main/resources/jenkins/plugins/git/GitHooksConfiguration/help-allowedOnController.html b/src/main/resources/jenkins/plugins/git/GitHooksConfiguration/help-allowedOnController.html new file mode 100644 index 0000000000..42144f7ccb --- /dev/null +++ b/src/main/resources/jenkins/plugins/git/GitHooksConfiguration/help-allowedOnController.html @@ -0,0 +1,25 @@ +
+

+ Git hooks allow scripts to be invoked when certain important git repository actions occur. + This configuration controls the execution of client-side hooks on the Jenkins controller. + It is recommended that git hooks be disabled on the controller. +

+

+ The Jenkins controller uses git repositories to checkout Pipeline definitions, to detect changes in remote repositories, and to cache Pipeline shared libraries. + Jenkins jobs that run on the controller may use git repositories in many other ways. + It is strongly recommended that jobs are not run on the Jenkins controller. + Refer to the controller isolation documentation for more details. +

+

+ Client-side hooks are not copied when the repository is cloned. + However, client-side hooks might be installed in a repository by build steps or by misconfiguration. +

+

+ If hook scripts are allowed on the controller, a client-side hook script installed in a repository on the Jenkins controller will execute when the matching git operation is performed. + For example, if hooks are allowed on the controller and a git repository on the controller includes a post-checkout hook, the hook script will run on the controller after any checkout in that repository. + If hooks are allowed on the controller and a git repository on the controller includes a pre-auto-gc hook, the hook script will run on the controller before any automatic git garbage collection task. +

+

+ See "Customizing Git - Git Hooks" for more details about git repository hooks. +

+
diff --git a/src/test/java/hudson/plugins/git/GitHooksTest.java b/src/test/java/hudson/plugins/git/GitHooksTest.java new file mode 100644 index 0000000000..909a1c3bda --- /dev/null +++ b/src/test/java/hudson/plugins/git/GitHooksTest.java @@ -0,0 +1,274 @@ +package hudson.plugins.git; + +import hudson.FilePath; +import hudson.Functions; +import hudson.model.Label; +import hudson.slaves.DumbSlave; +import hudson.tools.ToolProperty; +import jenkins.plugins.git.CliGitCommand; +import jenkins.plugins.git.GitHooksConfiguration; +import org.apache.commons.io.FileUtils; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.LoggerRule; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsIterableContaining.hasItem; +import static org.hamcrest.core.IsNot.not; +import static org.hamcrest.core.StringStartsWith.startsWith; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeFalse; + +public class GitHooksTest extends AbstractGitTestCase { + + @Rule + public LoggerRule lr = new LoggerRule(); + @ClassRule + public static BuildWatcher watcher = new BuildWatcher(); + + @BeforeClass + public static void setGitDefaults() throws Exception { + CliGitCommand gitCmd = new CliGitCommand(null); + gitCmd.setDefaults(); + } + + @Before + public void setGitTool() throws IOException { + lr.record(GitHooksConfiguration.class.getName(), Level.ALL).capture(1024); + GitTool tool = new GitTool("my-git", "git", Collections.>emptyList()); + rule.jenkins.getDescriptorByType(GitTool.DescriptorImpl.class).setInstallations(tool); + //Jenkins 2.308 changes the default label to "built-in" causing test failures when testing with newer core + // e.g. java 17 testing + rule.jenkins.setLabelString("master"); + rule.jenkins.setNumExecutors(3); //In case this changes in the future as well. + } + + @After + public void tearDown() { + GitHooksConfiguration.get().setAllowedOnController(false); + GitHooksConfiguration.get().setAllowedOnAgents(false); + assertThat(lr.getMessages(), not(hasItem(startsWith("core.hooksPath explicitly set to ")))); + } + + @Test + public void testPipelineFromScm() throws Exception { + assumeNotJenkinsCiWindows(); + GitHooksConfiguration.get().setAllowedOnController(true); + GitHooksConfiguration.get().setAllowedOnAgents(true); + final DumbSlave agent = rule.createOnlineSlave(Label.get("somewhere")); + commit("test.txt", "Test", johnDoe, "First"); + String jenkinsfile = lines( + "node('somewhere') {", + " checkout scm", + " echo 'Hello Pipeline'", + "}" + ); + commit("Jenkinsfile", jenkinsfile, johnDoe, "Jenkinsfile"); + final WorkflowJob job = rule.createProject(WorkflowJob.class); + final GitSCM scm = new GitSCM( + this.createRemoteRepositories(), + Collections.singletonList(new BranchSpec("master")), + null, "my-git", Collections.emptyList() + ); + CpsScmFlowDefinition definition = new CpsScmFlowDefinition(scm, "Jenkinsfile"); + definition.setLightweight(false); + job.setDefinition(definition); + job.save(); + WorkflowRun run = rule.buildAndAssertSuccess(job); + rule.assertLogContains("Hello Pipeline", run); + + final FilePath workspace = agent.getWorkspaceFor(job); + assertNotNull(workspace); + TemporaryFolder tf = new TemporaryFolder(); + tf.create(); + final File postCheckoutOutput1 = new File(tf.newFolder(), "svn-git-fun-post-checkout-1"); + final File postCheckoutOutput2 = new File(tf.newFolder(), "svn-git-fun-post-checkout-2"); + + //Add hook on agent workspace + FilePath hook = workspace.child(".git/hooks/post-checkout"); + createHookScriptAt(postCheckoutOutput1, hook); + + FilePath scriptWorkspace = rule.jenkins.getWorkspaceFor(job).withSuffix("@script"); + scriptWorkspace = scriptWorkspace.listDirectories().stream().findFirst().get(); + createHookScriptAt(postCheckoutOutput2, scriptWorkspace.child(".git/hooks/post-checkout")); + + commit("test.txt", "Second", johnDoe, "Second"); + commit("Jenkinsfile", "/*2*/\n" + jenkinsfile, johnDoe, "Jenkinsfile"); + + //Allowed + Thread.sleep(TimeUnit.SECONDS.toMillis(2)); + Instant before = Instant.now().minus(2, ChronoUnit.SECONDS); + run = rule.buildAndAssertSuccess(job); + assertTrue(postCheckoutOutput1.exists()); + assertTrue(postCheckoutOutput2.exists()); + rule.assertLogContains("Hello Pipeline", run); + Instant after = Instant.now().plus(2, ChronoUnit.SECONDS); + checkFileOutput(postCheckoutOutput1, before, after); + assertFalse(postCheckoutOutput1.exists()); + checkFileOutput(postCheckoutOutput2, before, after); + assertFalse(postCheckoutOutput2.exists()); + + commit("test.txt", "Third", johnDoe, "Third"); + commit("Jenkinsfile", "/*3*/\n" + jenkinsfile, johnDoe, "Jenkinsfile"); + //Denied + GitHooksConfiguration.get().setAllowedOnController(false); + GitHooksConfiguration.get().setAllowedOnAgents(false); + run = rule.buildAndAssertSuccess(job); + rule.assertLogContains("Hello Pipeline", run); + assertFalse(postCheckoutOutput1.exists()); + assertFalse(postCheckoutOutput2.exists()); + + commit("test.txt", "Four", johnDoe, "Four"); + commit("Jenkinsfile", "/*4*/\n" + jenkinsfile, johnDoe, "Jenkinsfile"); + //Allowed On Agent + GitHooksConfiguration.get().setAllowedOnController(false); + GitHooksConfiguration.get().setAllowedOnAgents(true); + Thread.sleep(TimeUnit.SECONDS.toMillis(2)); + before = Instant.now().minus(2, ChronoUnit.SECONDS); + run = rule.buildAndAssertSuccess(job); + assertFalse(postCheckoutOutput2.exists()); + assertTrue(postCheckoutOutput1.exists()); + rule.assertLogContains("Hello Pipeline", run); + after = Instant.now().plus(2, ChronoUnit.SECONDS); + checkFileOutput(postCheckoutOutput1, before, after); + assertFalse(postCheckoutOutput1.exists()); + + commit("test.txt", "Five", johnDoe, "Five"); + commit("Jenkinsfile", "/*5*/\n" + jenkinsfile, johnDoe, "Jenkinsfile"); + //Denied + GitHooksConfiguration.get().setAllowedOnController(false); + GitHooksConfiguration.get().setAllowedOnAgents(false); + run = rule.buildAndAssertSuccess(job); + rule.assertLogContains("Hello Pipeline", run); + assertFalse(postCheckoutOutput1.exists()); + assertFalse(postCheckoutOutput2.exists()); + } + + private void createHookScriptAt(final File postCheckoutOutput, final FilePath hook) throws IOException, InterruptedException { + final String nl = System.lineSeparator(); + StringBuilder scriptContent = new StringBuilder("#!/bin/bash -v").append(nl); + scriptContent.append("date +%s > \"") + .append(postCheckoutOutput.getAbsolutePath().replace("\\", "\\\\")) //Git bash does the bash escaping so need to do more escaping + .append('"').append(nl); + hook.write(scriptContent.toString(), Charset.defaultCharset().name()); + hook.chmod(0777); + } + + private void checkFileOutput(final File postCheckoutOutput, final Instant before, final Instant after) throws IOException { + assertTrue("Output file should exist", postCheckoutOutput.exists()); + final String s = FileUtils.readFileToString(postCheckoutOutput, Charset.defaultCharset()).trim(); + final Instant when = Instant.ofEpochSecond(Integer.parseInt(s)); + assertTrue("Sometime else", when.isAfter(before) && when.isBefore(after)); + Files.delete(postCheckoutOutput.toPath()); + } + + @Test + public void testPipelineCheckoutController() throws Exception { + assumeNotJenkinsCiWindows(); + + final WorkflowJob job = setupAndRunPipelineCheckout("master"); + WorkflowRun run; + commit("Commit3", janeDoe, "Commit number 3"); + GitHooksConfiguration.get().setAllowedOnController(true); + run = rule.buildAndAssertSuccess(job); + rule.assertLogContains("h4xor3d", run); + GitHooksConfiguration.get().setAllowedOnController(false); + GitHooksConfiguration.get().setAllowedOnAgents(true); + commit("Commit4", janeDoe, "Commit number 4"); + run = rule.buildAndAssertSuccess(job); + rule.assertLogNotContains("h4xor3d", run); + } + + @Test + public void testPipelineCheckoutAgent() throws Exception { + assumeNotJenkinsCiWindows(); + + rule.createOnlineSlave(Label.get("belsebob")); + final WorkflowJob job = setupAndRunPipelineCheckout("belsebob"); + WorkflowRun run; + commit("Commit3", janeDoe, "Commit number 3"); + GitHooksConfiguration.get().setAllowedOnAgents(true); + run = rule.buildAndAssertSuccess(job); + rule.assertLogContains("h4xor3d", run); + GitHooksConfiguration.get().setAllowedOnAgents(false); + GitHooksConfiguration.get().setAllowedOnController(true); + commit("Commit4", janeDoe, "Commit number 4"); + run = rule.buildAndAssertSuccess(job); + rule.assertLogNotContains("h4xor3d", run); + } + + private WorkflowJob setupAndRunPipelineCheckout(String node) throws Exception { + final String commitFile1 = "commitFile1"; + commit(commitFile1, johnDoe, "Commit number 1"); + + final WorkflowJob job = rule.createProject(WorkflowJob.class); + final String uri = testRepo.gitDir.getAbsolutePath().replace("\\", "/"); + job.setDefinition(new CpsFlowDefinition(lines( + "node('" + node + "') {", + " checkout([$class: 'GitSCM', branches: [[name: '*/master']], userRemoteConfigs: [[url: '" + uri + "']]])", + " if (!fileExists('.git/hooks/post-checkout')) {", + " writeFile file: '.git/hooks/post-checkout', text: \"#!/bin/bash\\necho h4xor3d\"", + " if (isUnix()) {", + " sh 'chmod +x .git/hooks/post-checkout'", + " }", + " } else {", + " if (isUnix()) {", + " sh 'git checkout -B test origin/master'", + " } else {", + " bat 'git.exe checkout -B test origin/master'", + " }", + " }", + "}") + , true)); + WorkflowRun run = rule.buildAndAssertSuccess(job); + rule.assertLogNotContains("h4xor3d", run); + final String commitFile2 = "commitFile2"; + commit(commitFile2, janeDoe, "Commit number 2"); + run = rule.buildAndAssertSuccess(job); + rule.assertLogNotContains("h4xor3d", run); + return job; + } + + /** + * Approximates a multiline string in Java. + * + * @param lines the lines to concatenate with a newline separator + * @return the concatenated multiline string + */ + private static String lines(String... lines) { + return String.join("\n", lines); + } + + /** + * Assume that it is not running on a ci.jenkins.io Windows agent. + * + * The tests are tested and confirmed working on Windows, + * but for unknown reason is not working on the Win agents on ci.jenkins.io. + */ + private void assumeNotJenkinsCiWindows() { + final String jenkinsUrl = System.getenv("JENKINS_URL"); + assumeFalse(Functions.isWindows() && jenkinsUrl.contains("ci.jenkins.io")); + } +} diff --git a/src/test/java/jenkins/plugins/git/GitHooksConfigurationTest.java b/src/test/java/jenkins/plugins/git/GitHooksConfigurationTest.java new file mode 100644 index 0000000000..c12e16f5d2 --- /dev/null +++ b/src/test/java/jenkins/plugins/git/GitHooksConfigurationTest.java @@ -0,0 +1,211 @@ +/* + * The MIT License + * + * Copyright 2021 CloudBees, Inc. + * + * 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 jenkins.plugins.git; + +import hudson.EnvVars; +import hudson.model.TaskListener; +import java.io.File; +import java.io.IOException; +import java.util.Random; +import org.eclipse.jgit.lib.StoredConfig; +import org.jenkinsci.plugins.gitclient.Git; +import org.jenkinsci.plugins.gitclient.GitClient; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class GitHooksConfigurationTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + private GitHooksConfiguration configuration; + private GitClient client; + + private final Random random = new Random(); + private static final String NULL_HOOKS_PATH = isWindows() ? "NUL:" : "/dev/null"; + + public GitHooksConfigurationTest() { + } + + @Before + public void setUp() throws IOException, InterruptedException { + configuration = GitHooksConfiguration.get(); + Git git = Git.with(TaskListener.NULL, new EnvVars()); + client = git.getClient(); + } + + @After + public void resetHooksPath() throws IOException, InterruptedException { + client.withRepository((repo, channel) -> { + final StoredConfig repoConfig = repo.getConfig(); + repoConfig.unset("core", null, "hooksPath"); + repoConfig.save(); + return null; + }); + } + + @Test + public void testGet() { + assertThat(GitHooksConfiguration.get(), is(configuration)); + } + + @Test + public void testIsAllowedOnController() { + assertFalse(configuration.isAllowedOnController()); + } + + @Test + public void testSetAllowedOnController() { + configuration.setAllowedOnController(true); + assertTrue(configuration.isAllowedOnController()); + } + + @Test + public void testSetAllowedOnControllerFalse() { + configuration.setAllowedOnController(false); + assertFalse(configuration.isAllowedOnController()); + } + + @Test + public void testIsAllowedOnAgents() { + assertFalse(configuration.isAllowedOnAgents()); + } + + @Test + public void testSetAllowedOnAgents() { + configuration.setAllowedOnAgents(true); + assertTrue(configuration.isAllowedOnAgents()); + } + + @Test + public void testSetAllowedOnAgentsFalse() { + configuration.setAllowedOnAgents(false); + assertFalse(configuration.isAllowedOnAgents()); + } + + @Test + public void testGetCategory() { + assertThat(GitHooksConfiguration.get().getCategory(), is(configuration.getCategory())); + } + + private void setCoreHooksPath(String hooksPath) throws IOException, InterruptedException { + /* Configure a core.hook with path `hooksPath` */ + client.withRepository((repo, channel) -> { + final StoredConfig repoConfig = repo.getConfig(); + repoConfig.setString("core", null, "hooksPath", hooksPath); + repoConfig.save(); + return null; + }); + } + + private String getCoreHooksPath() throws IOException, InterruptedException { + String hooksPath = client.withRepository((repo, channel) -> { + final StoredConfig repoConfig = repo.getConfig(); + return repoConfig.getString("core", null, "hooksPath"); + }); + return hooksPath; + } + + @Test + public void testConfigure_GitClient() throws Exception { + GitHooksConfiguration.configure(client); + + /* Check configured value from repository */ + String hooksPath = getCoreHooksPath(); + assertThat(hooksPath, is(NULL_HOOKS_PATH)); + } + + @Test + public void testConfigure_GitClient_boolean() throws Exception { + boolean allowed = true; + GitHooksConfiguration.configure(client, allowed); + + /* Check configured value from repository */ + String hooksPath = getCoreHooksPath(); + assertThat(hooksPath, is(nullValue())); + } + + @Test + public void testConfigure_GitClient_booleanFalse() throws Exception { + boolean allowed = false; + GitHooksConfiguration.configure(client, allowed); + + /* Check configured value from repository */ + String hooksPath = getCoreHooksPath(); + assertThat(hooksPath, is(NULL_HOOKS_PATH)); + } + + private final String ALTERNATE_HOOKS_PATH = "not-a-valid-hooks-path"; + + private void configure_3args(boolean allowedOnController) throws Exception { + /* Change the hooksPath in repository */ + setCoreHooksPath(ALTERNATE_HOOKS_PATH); + + /* Confirm the hooksPath was changed in repository */ + String hooksPathBefore = getCoreHooksPath(); + assertThat(hooksPathBefore, is(ALTERNATE_HOOKS_PATH)); + + /* Reconfigure repository. + * Agent arg is ignored on controller, thus pass a random boolean + */ + GitHooksConfiguration.configure(client, allowedOnController, random.nextBoolean()); + } + + @Test + public void testConfigure_3args() throws Exception { + boolean allowedOnController = true; + + /* Change the hooksPath in repository */ + configure_3args(allowedOnController); + + /* Check configured value from repository */ + String hooksPath = getCoreHooksPath(); + assertThat(hooksPath, is(ALTERNATE_HOOKS_PATH)); + } + + @Test + public void testConfigure_3argsFalse() throws Exception { + boolean allowedOnController = false; + + /* Change the hooksPath in repository */ + configure_3args(allowedOnController); + + /* Check configured value from repository */ + String hooksPath = getCoreHooksPath(); + assertThat(hooksPath, is(NULL_HOOKS_PATH)); + } + + private static boolean isWindows() { + return File.pathSeparatorChar == ';'; + } +}