Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ public class CliGitAPIImpl extends LegacyCompatibleGitAPIImpl {
*/
private Map<Instant, String> 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"
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 15 additions & 3 deletions src/main/java/org/jenkinsci/plugins/gitclient/Git.java
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@ public GitClient getClient() throws IOException, InterruptedException {
.getVerifier();
}
}
jenkins.MasterToSlaveFileCallable<GitClient> callable = new GitAPIMasterToSlaveFileCallable(hostKeyFactory);
boolean sshVerbose = Jenkins.getInstanceOrNull() != null
&& GitHostKeyVerificationConfiguration.get().isSshVerbose();
jenkins.MasterToSlaveFileCallable<GitClient> 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) {
Expand Down Expand Up @@ -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
Expand All @@ -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)) {
Expand All @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public class GitHostKeyVerificationConfiguration extends GlobalConfiguration imp

private SshHostKeyVerificationStrategy<? extends HostKeyVerifierFactory> sshHostKeyVerificationStrategy;

private boolean sshVerbose = false;

@Override
public @NonNull GlobalConfigurationCategory getCategory() {
return GlobalConfigurationCategory.get(GlobalConfigurationCategory.Security.class);
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form">
<f:section title="Git Host Key Verification Configuration">
<f:dropdownDescriptorSelector field="sshHostKeyVerificationStrategy" title="Host Key Verification Strategy"/>
<f:entry title="SSH Verbose Mode" field="sshVerbose">
<f:checkbox title="Enable verbose SSH output for diagnostics"/>
</f:entry>
</f:section>
</j:jelly>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div>
When enabled, SSH commands generated by the Git client will include <code>-vvv</code> for detailed diagnostic output.
This helps troubleshoot SSH connection issues without requiring the <code>GIT_SSH_COMMAND</code> environment variable.
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +36,7 @@
*
* @author Mark Waite
*/
@WithJenkins
class CliGitAPISecurityTest {

@TempDir
Expand Down Expand Up @@ -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);
}
}
}
Loading