diff --git a/src/main/java/org/jenkinsci/plugins/gitclient/CliGitAPIImpl.java b/src/main/java/org/jenkinsci/plugins/gitclient/CliGitAPIImpl.java index 8fe922e6c8..a499aea4c4 100644 --- a/src/main/java/org/jenkinsci/plugins/gitclient/CliGitAPIImpl.java +++ b/src/main/java/org/jenkinsci/plugins/gitclient/CliGitAPIImpl.java @@ -205,6 +205,8 @@ public class CliGitAPIImpl extends LegacyCompatibleGitAPIImpl { */ private Map failureClues = new TreeMap<>(); + private boolean sshVerbose; + /* git config --get-regex applies the regex to match keys, and returns all matches (including substring matches). * Thus, a config call: * git config -f .gitmodules --get-regexp "^submodule\.(.+)\.url" @@ -348,7 +350,9 @@ protected CliGitAPIImpl(String gitExe, File workspace, TaskListener listener, En /** {@inheritDoc} */ @Override public GitClient subGit(String subdir) { - return new CliGitAPIImpl(gitExe, new File(workspace, subdir), listener, environment); + CliGitAPIImpl git = new CliGitAPIImpl(gitExe, new File(workspace, subdir), listener, environment); + git.setSshVerbose(sshVerbose); + return git; } /** @@ -2700,9 +2704,11 @@ Path createWindowsGitSSH(Path key, String user, Path knownHosts) throws IOExcept w.newLine(); w.write("setlocal enabledelayedexpansion"); w.newLine(); + String verboseFlag = sshVerbose ? " -vvv" : ""; w.write("\"" + sshexe.getAbsolutePath() + "\" -i \"!JENKINS_GIT_SSH_KEYFILE!\" -l \"!JENKINS_GIT_SSH_USERNAME!\" " - + getHostKeyFactory().forCliGit(listener).getVerifyHostKeyOption(knownHosts) + " %* "); + + getHostKeyFactory().forCliGit(listener).getVerifyHostKeyOption(knownHosts) + verboseFlag + + " %* "); w.newLine(); } ssh.toFile().setExecutable(true, true); @@ -2724,8 +2730,10 @@ Path createUnixGitSSH(Path key, String user, Path knownHosts) throws IOException w.newLine(); w.write("fi"); w.newLine(); + String verboseFlag = sshVerbose ? " -vvv" : ""; w.write("ssh -i \"$JENKINS_GIT_SSH_KEYFILE\" -l \"$JENKINS_GIT_SSH_USERNAME\" " - + getHostKeyFactory().forCliGit(listener).getVerifyHostKeyOption(knownHosts) + " \"$@\""); + + getHostKeyFactory().forCliGit(listener).getVerifyHostKeyOption(knownHosts) + verboseFlag + + " \"$@\""); w.newLine(); } return createNonBusyExecutable(ssh); @@ -2768,6 +2776,10 @@ private String launchCommandIn(ArgumentListBuilder args, File workDir) throws Gi return launchCommandIn(args, workDir, environment); } + public void setSshVerbose(boolean sshVerbose) { + this.sshVerbose = sshVerbose; + } + private String launchCommandIn(ArgumentListBuilder args, File workDir, EnvVars env) throws GitException, InterruptedException { return launchCommandIn(args, workDir, environment, TIMEOUT); diff --git a/src/main/java/org/jenkinsci/plugins/gitclient/Git.java b/src/main/java/org/jenkinsci/plugins/gitclient/Git.java index a6f485ba14..5bd0c6baa5 100644 --- a/src/main/java/org/jenkinsci/plugins/gitclient/Git.java +++ b/src/main/java/org/jenkinsci/plugins/gitclient/Git.java @@ -141,7 +141,10 @@ public GitClient getClient() throws IOException, InterruptedException { .getVerifier(); } } - jenkins.MasterToSlaveFileCallable callable = new GitAPIMasterToSlaveFileCallable(hostKeyFactory); + boolean sshVerbose = Jenkins.getInstanceOrNull() != null + && GitHostKeyVerificationConfiguration.get().isSshVerbose(); + jenkins.MasterToSlaveFileCallable callable = + new GitAPIMasterToSlaveFileCallable(hostKeyFactory, sshVerbose); GitClient git = (repository != null ? repository.act(callable) : callable.invoke(null, null)); Jenkins jenkinsInstance = Jenkins.getInstanceOrNull(); if (jenkinsInstance != null && git != null) { @@ -185,8 +188,11 @@ private class GitAPIMasterToSlaveFileCallable extends jenkins.MasterToSlaveFileC private final HostKeyVerifierFactory hostKeyFactory; - public GitAPIMasterToSlaveFileCallable(HostKeyVerifierFactory hostKeyFactory) { + private final boolean sshVerbose; + + public GitAPIMasterToSlaveFileCallable(HostKeyVerifierFactory hostKeyFactory, boolean sshVerbose) { this.hostKeyFactory = hostKeyFactory; + this.sshVerbose = sshVerbose; } @Override @@ -199,7 +205,12 @@ public GitClient invoke(File f, VirtualChannel channel) throws IOException, Inte } if (Main.isUnitTest && System.getProperty(Git.class.getName() + ".mockClient") != null) { - return initMockClient(System.getProperty(Git.class.getName() + ".mockClient"), exe, env, f, listener); + GitClient mockClient = + initMockClient(System.getProperty(Git.class.getName() + ".mockClient"), exe, env, f, listener); + if (mockClient instanceof CliGitAPIImpl cliGitAPIImpl) { + cliGitAPIImpl.setSshVerbose(sshVerbose); + } + return mockClient; } if (exe == null || JGitTool.MAGIC_EXENAME.equalsIgnoreCase(exe)) { @@ -212,6 +223,7 @@ public GitClient invoke(File f, VirtualChannel channel) throws IOException, Inte // Ensure we return a backward compatible GitAPI, even API only claim to provide a GitClient GitAPI gitAPI = new GitAPI(exe, f, listener, env); gitAPI.setHostKeyFactory(hostKeyFactory); + gitAPI.setSshVerbose(sshVerbose); return gitAPI; } } diff --git a/src/main/java/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration.java b/src/main/java/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration.java index a3831d55a5..c22d4b481c 100644 --- a/src/main/java/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration.java +++ b/src/main/java/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration.java @@ -16,6 +16,8 @@ public class GitHostKeyVerificationConfiguration extends GlobalConfiguration imp private SshHostKeyVerificationStrategy sshHostKeyVerificationStrategy; + private boolean sshVerbose = false; + @Override public @NonNull GlobalConfigurationCategory getCategory() { return GlobalConfigurationCategory.get(GlobalConfigurationCategory.Security.class); @@ -35,6 +37,29 @@ public void setSshHostKeyVerificationStrategy( save(); } + /** + * Check if SSH verbose mode is enabled. + * When enabled, SSH commands will include -vvv flag for detailed diagnostic output. + * This helps troubleshoot SSH connection issues without requiring GIT_SSH_COMMAND environment variable. + * + * @return true if SSH verbose mode is enabled, false otherwise + */ + public boolean isSshVerbose() { + return sshVerbose; + } + + /** + * Set SSH verbose mode. + * When enabled, SSH commands will include -vvv flag for detailed diagnostic output. + * This helps troubleshoot SSH connection issues without requiring GIT_SSH_COMMAND environment variable. + * + * @param sshVerbose true to enable SSH verbose mode, false to disable + */ + public void setSshVerbose(boolean sshVerbose) { + this.sshVerbose = sshVerbose; + save(); + } + public static @NonNull GitHostKeyVerificationConfiguration get() { return GlobalConfiguration.all().getInstance(GitHostKeyVerificationConfiguration.class); } diff --git a/src/main/resources/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration/config.jelly b/src/main/resources/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration/config.jelly index 3461e5681e..126b807495 100644 --- a/src/main/resources/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration/config.jelly +++ b/src/main/resources/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration/config.jelly @@ -2,5 +2,8 @@ + + + \ No newline at end of file diff --git a/src/main/resources/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration/help-sshVerbose.html b/src/main/resources/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration/help-sshVerbose.html new file mode 100644 index 0000000000..11b02beee2 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/gitclient/GitHostKeyVerificationConfiguration/help-sshVerbose.html @@ -0,0 +1,4 @@ +
+ When enabled, SSH commands generated by the Git client will include -vvv for detailed diagnostic output. + This helps troubleshoot SSH connection issues without requiring the GIT_SSH_COMMAND environment variable. +
diff --git a/src/test/java/org/jenkinsci/plugins/gitclient/CliGitAPISecurityTest.java b/src/test/java/org/jenkinsci/plugins/gitclient/CliGitAPISecurityTest.java index 60ade1b268..8449f08d5a 100644 --- a/src/test/java/org/jenkinsci/plugins/gitclient/CliGitAPISecurityTest.java +++ b/src/test/java/org/jenkinsci/plugins/gitclient/CliGitAPISecurityTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; /** * Security test that proves the environment variable approach prevents @@ -35,6 +36,7 @@ * * @author Mark Waite */ +@WithJenkins class CliGitAPISecurityTest { @TempDir @@ -286,4 +288,158 @@ private void executeWrapper(Path wrapper, Path keyFile) throws Exception { // That's fine - we're just checking for injection } } + + /** + * Test that SSH verbose mode is disabled by default (no -vvv flag) + */ + @Test + @Issue("JENKINS-71461") + void testSshVerboseModeDisabledByDefault() throws Exception { + workspace = new File(tempDir, "test-default"); + workspace.mkdirs(); + + Path keyFile = createMockSSHKey(workspace); + Path knownHosts = Files.createTempFile("known_hosts", ""); + + try { + GitClient gitClient = Git.with(TaskListener.NULL, new EnvVars()) + .in(workspace) + .using("git") + .getClient(); + CliGitAPIImpl git = (CliGitAPIImpl) gitClient; + + Path sshWrapper; + if (isWindows()) { + sshWrapper = git.createWindowsGitSSH(keyFile, "testuser", knownHosts); + } else { + sshWrapper = git.createUnixGitSSH(keyFile, "testuser", knownHosts); + } + + String wrapperContent = Files.readString(sshWrapper, StandardCharsets.UTF_8); + + // Verify -vvv flag is NOT present + assertFalse( + wrapperContent.contains("-vvv"), + "Wrapper should NOT contain -vvv flag when verbose mode is disabled"); + + } finally { + Files.deleteIfExists(knownHosts); + } + } + + /** + * Test that SSH verbose mode adds -vvv flag when enabled + */ + @Test + @Issue("JENKINS-71461") + void testSshVerboseModeEnabled() throws Exception { + workspace = new File(tempDir, "test-verbose"); + workspace.mkdirs(); + + Path keyFile = createMockSSHKey(workspace); + Path knownHosts = Files.createTempFile("known_hosts", ""); + + try { + GitClient gitClient = Git.with(TaskListener.NULL, new EnvVars()) + .in(workspace) + .using("git") + .getClient(); + CliGitAPIImpl git = (CliGitAPIImpl) gitClient; + git.setSshVerbose(true); + + Path sshWrapper; + if (isWindows()) { + sshWrapper = git.createWindowsGitSSH(keyFile, "testuser", knownHosts); + } else { + sshWrapper = git.createUnixGitSSH(keyFile, "testuser", knownHosts); + } + + String wrapperContent = Files.readString(sshWrapper, StandardCharsets.UTF_8); + + // Verify -vvv flag IS present + assertTrue( + wrapperContent.contains("-vvv"), "Wrapper should contain -vvv flag when verbose mode is enabled"); + + } finally { + Files.deleteIfExists(knownHosts); + } + } + + /** + * Test that SSH verbose mode flag is placed correctly in Unix wrapper + */ + @Test + @Issue("JENKINS-71461") + void testUnixSshVerboseFlagPlacement() throws Exception { + if (isWindows()) { + return; + } + + workspace = new File(tempDir, "test-unix-verbose"); + workspace.mkdirs(); + + Path keyFile = createMockSSHKey(workspace); + Path knownHosts = Files.createTempFile("known_hosts", ""); + + try { + GitClient gitClient = Git.with(TaskListener.NULL, new EnvVars()) + .in(workspace) + .using("git") + .getClient(); + CliGitAPIImpl git = (CliGitAPIImpl) gitClient; + git.setSshVerbose(true); + Path sshWrapper = git.createUnixGitSSH(keyFile, "testuser", knownHosts); + + String wrapperContent = Files.readString(sshWrapper, StandardCharsets.UTF_8); + + // Verify -vvv appears before "$@" (which represents additional args) + int vvvIndex = wrapperContent.indexOf("-vvv"); + int argsIndex = wrapperContent.indexOf("\"$@\""); + assertTrue(vvvIndex > 0, "-vvv flag should be present"); + assertTrue(argsIndex > 0, "\"$@\" should be present"); + assertTrue(vvvIndex < argsIndex, "-vvv flag should appear before \"$@\""); + + } finally { + Files.deleteIfExists(knownHosts); + } + } + + /** + * Test that SSH verbose mode flag is placed correctly in Windows wrapper + */ + @Test + @Issue("JENKINS-71461") + void testWindowsSshVerboseFlagPlacement() throws Exception { + if (!isWindows()) { + return; + } + + workspace = new File(tempDir, "test-windows-verbose"); + workspace.mkdirs(); + + Path keyFile = createMockSSHKey(workspace); + Path knownHosts = Files.createTempFile("known_hosts", ""); + + try { + GitClient gitClient = Git.with(TaskListener.NULL, new EnvVars()) + .in(workspace) + .using("git") + .getClient(); + CliGitAPIImpl git = (CliGitAPIImpl) gitClient; + git.setSshVerbose(true); + Path sshWrapper = git.createWindowsGitSSH(keyFile, "testuser", knownHosts); + + String wrapperContent = Files.readString(sshWrapper, StandardCharsets.UTF_8); + + // Verify -vvv appears before %* (which represents additional args) + int vvvIndex = wrapperContent.indexOf("-vvv"); + int argsIndex = wrapperContent.indexOf("%*"); + assertTrue(vvvIndex > 0, "-vvv flag should be present"); + assertTrue(argsIndex > 0, "%* should be present"); + assertTrue(vvvIndex < argsIndex, "-vvv flag should appear before %*"); + + } finally { + Files.deleteIfExists(knownHosts); + } + } }