diff --git a/README.adoc b/README.adoc index 3d50b59574..724944a4d3 100644 --- a/README.adoc +++ b/README.adoc @@ -54,7 +54,10 @@ image:images/multibranch-pipeline.png[link=https://youtu.be/B_2FXWI6CWg] [#credential-binding] === Git Credentials Binding -The git plugin provides `Git Username and Password` binding that allows authenticated git operations over *HTTP* and *HTTPS* protocols using command line git in a Pipeline job. +The git plugin provides two credential bindings + +* `Git Username and Password` binding that allows authenticated git operations over *HTTP* and *HTTPS* protocols using command line git in a Pipeline job. +* `Git SSH Private Key` binding that allows authenticated git operations over *SSH* protocol using command line git for sh, bat, powershell in a Pipeline job. The git credential bindings are accessible through the link:https://www.jenkins.io/doc/pipeline/steps/credentials-binding/#withcredentials-bind-credentials-to-variables[`withCredentials`] step of the link:https://plugins.jenkins.io/credentials-binding/[Credentials Binding] plugin. The binding retrieves credentials from the link:https://plugins.jenkins.io/credentials/[Credentials] plugin. @@ -96,6 +99,45 @@ withCredentials([gitUsernamePassword(credentialsId: 'my-credentials-id', powershell 'git push' } ``` + +==== Git SSH Private Key + +The binding provides *SSH* protocol support for git operation which requires authentication. + +Procedure:: + +. Click the Pipeline Syntax _Snippet Generator_ and choose the `withCredentials` step, add Git SSH Private Key binding. +. Choose the required credentials and Git tool name, specific to the generated Pipeline snippet. + +image:images/git-credentials-binding-git-ssh-private-key-pipeline-job.png[Git-SSH-Private-Key-Binding] + +Unlike `Git Username and Password` binding, variable bindings are not provided in `Git SSH Private Key` binding. + + +.Shell example +```groovy +withCredentials([gitSshPrivateKey(credentialsId: 'my-credentials-id', + gitToolName: 'git-tool')]) { + sh 'git fetch --all' +} +``` + +.Batch example +```groovy +withCredentials([gitSshPrivateKey(credentialsId: 'my-credentials-id', + gitToolName: 'git-tool')]) { + bat 'git submodule update --init --recursive' +} +``` + +.Powershell example +```groovy +withCredentials([gitSshPrivateKey(credentialsId: 'my-credentials-id', + gitToolName: 'git-tool')]) { + powershell 'git push' +} +``` + [#configuration] == [[GitPlugin-ProjectConfiguration]]Configuration @@ -592,13 +634,18 @@ Project Name in ViewGit:: [#git-bindings] == Git Credential Binding -The git plugin provides one binding to support authenticated git operations over *HTTP* or *HTTPS* protocol, namely `Git Username and Password`. -The git plugin depends on the Credential Binding Plugin to support these bindings. +The git plugin provides two binding to support authenticated git operations- + +* `Git Username and Password` supports git authentication over *HTTP* or *HTTPS* protocol. +* `Git SSH Private Key` supports git authentication over *SSH* protocol. + +The git plugin depends on the https://plugins.jenkins.io/credentials-binding/[Credential Binding Plugin] to support these bindings. -To access the `Git Username and Password` binding in a Pipeline job, visit <> +To access the `Git Username and Password` and `Git SSH Private Key` binding in a Pipeline job, visit <> -Freestyle projects can use git credential binding with the following steps: +Freestyle projects can use git credentials binding +Git Username and Password:: . Check the box _Use secret text(s) or file(s)_, add Git Username and Password binding. . Choose the required credentials and Git tool name. @@ -608,6 +655,14 @@ image:images/git-credentials-usernamepassword-binding-freestyle-project.png[Git- Two variable bindings are used, `GIT_USERNAME` and `GIT_PASSWORD`, to pass the username and password to shell, batch, and powershell steps in a Freestyle job. The variable bindings are available even if the `JGit` or `JGit with Apache HTTP Client` git implementation is being used. +Git SSH Private Key:: +. Check the box _Use secret text(s) or file(s)_, add Git SSH Private Key binding. + +. Choose the required credentials and Git tool name. + +image:images/git-credentials-binding-git-ssh-private-key-freestyle-job.png[Git-SSH-Private-Key-Freestyle-Job] + +Variable bindings are not provided in this binding. [#extensions] == Extensions diff --git a/images/git-credentials-binding-git-ssh-private-key-freestyle-job.png b/images/git-credentials-binding-git-ssh-private-key-freestyle-job.png new file mode 100644 index 0000000000..d8bf24a4b2 Binary files /dev/null and b/images/git-credentials-binding-git-ssh-private-key-freestyle-job.png differ diff --git a/images/git-credentials-binding-git-ssh-private-key-pipeline-job.png b/images/git-credentials-binding-git-ssh-private-key-pipeline-job.png new file mode 100644 index 0000000000..8b80ae0e1a Binary files /dev/null and b/images/git-credentials-binding-git-ssh-private-key-pipeline-job.png differ diff --git a/pom.xml b/pom.xml index ef420d7d52..b4a278590b 100644 --- a/pom.xml +++ b/pom.xml @@ -178,6 +178,17 @@ org.jenkins-ci.plugins credentials-binding + + + org.jenkins-ci.plugins + bouncycastle-api + + + + org.jenkins-ci.modules + sshd + 3.1.0 + org.jenkins-ci.plugins diff --git a/src/main/java/jenkins/plugins/git/GitSSHPrivateKeyBinding.java b/src/main/java/jenkins/plugins/git/GitSSHPrivateKeyBinding.java new file mode 100644 index 0000000000..3c2c41098d --- /dev/null +++ b/src/main/java/jenkins/plugins/git/GitSSHPrivateKeyBinding.java @@ -0,0 +1,219 @@ +package jenkins.plugins.git; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.plugins.credentials.common.StandardCredentials; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.EnvVars; +import hudson.FilePath; +import hudson.Launcher; +import hudson.model.Run; +import hudson.model.TaskListener; +import hudson.plugins.git.GitSCM; +import hudson.plugins.git.GitTool; +import hudson.util.ListBoxModel; +import hudson.Extension; +import jenkins.model.Jenkins; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.credentialsbinding.BindingDescriptor; +import org.jenkinsci.plugins.credentialsbinding.MultiBinding; +import org.jenkinsci.plugins.credentialsbinding.impl.AbstractOnDiskBinding; +import org.jenkinsci.plugins.credentialsbinding.impl.UnbindableDir; +import org.jenkinsci.plugins.gitclient.CliGitAPIImpl; +import org.jenkinsci.plugins.gitclient.Git; +import org.jenkinsci.plugins.gitclient.GitClient; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.interceptor.RequirePOST; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Collections; +import java.util.Map; +import java.util.List; +import java.util.Set; + + +public class GitSSHPrivateKeyBinding extends MultiBinding implements GitCredentialBindings, SSHKeyUtils { + final private String gitToolName; + private transient boolean unixNodeType; + + @DataBoundConstructor + public GitSSHPrivateKeyBinding(String gitToolName, String credentialsId) { + super(credentialsId); + this.gitToolName = gitToolName; + //Variables could be added if needed + } + + public String getGitToolName() { + return this.gitToolName; + } + + private void setUnixNodeType(boolean value) { + this.unixNodeType = value; + } + + @Override + public MultiEnvironment bind(@NonNull Run run, @Nullable FilePath filePath, + @Nullable Launcher launcher, @NonNull TaskListener taskListener) throws IOException, InterruptedException { + final Map secretValues = new LinkedHashMap<>(); + final Map publicValues = new LinkedHashMap<>(); + SSHUserPrivateKey credentials = getCredentials(run); + GitTool cliGitTool = getCliGitTool(run, this.gitToolName, taskListener); + if (cliGitTool != null && filePath != null && launcher != null) { + setUnixNodeType(isCurrentNodeOSUnix(launcher)); + final UnbindableDir unbindTempDir = UnbindableDir.create(filePath); + final GitClient git = getGitClientInstance(cliGitTool.getGitExe(), unbindTempDir.getDirPath(), new EnvVars(), taskListener); + final String sshExePath = getSSHPath(git); + setGitEnvironmentVariables(git, publicValues); + if (isGitVersionAtLeast(git, 2, 3, 0, 0)) { + secretValues.put("GIT_SSH_COMMAND", getSSHCmd(credentials, unbindTempDir.getDirPath(), sshExePath)); + } else { + SSHScriptFile sshScript = new SSHScriptFile(credentials, sshExePath, unixNodeType); + secretValues.put("GIT_SSH", sshScript.write(credentials, unbindTempDir.getDirPath()).getRemote()); + } + return new MultiEnvironment(secretValues, publicValues, unbindTempDir.getUnbinder()); + } else { + taskListener.getLogger().println("JGit and JGitApache type Git tools are not supported by this binding"); + return new MultiEnvironment(secretValues, publicValues); + } + } + + @Override + public Set variables(@NonNull Run run) { + return Collections.emptySet(); + } + + /*package*/void setGitEnvironmentVariables(@NonNull GitClient git, Map publicValues) throws IOException, InterruptedException { + setGitEnvironmentVariables(git, null, publicValues); + } + + @Override + public void setCredentialPairBindings(@NonNull StandardCredentials credentials, Map secretValues, Map publicValues) { + //Private Key credentials not required to bind with environment variables + } + + @Override + public void setGitEnvironmentVariables(@NonNull GitClient git, Map secretValues, Map publicValues) throws IOException, InterruptedException { + if (unixNodeType && isGitVersionAtLeast(git, 2, 3, 0, 0)) { + publicValues.put("GIT_TERMINAL_PROMPT", "false"); + } else { + publicValues.put("GCM_INTERACTIVE", "false"); + } + } + + @Override + public GitClient getGitClientInstance(String gitToolExe, FilePath repository, EnvVars env, TaskListener listener) throws IOException, InterruptedException { + Git gitInstance = Git.with(listener, env).using(gitToolExe); + return gitInstance.getClient(); + } + + @Override + protected Class type() { + return SSHUserPrivateKey.class; + } + + private boolean isGitVersionAtLeast(GitClient git, int major, int minor, int rev, int bugfix) { + return ((CliGitAPIImpl) git).isCliGitVerAtLeast(major, minor, rev, bugfix); + } + + private String getSSHPath(GitClient git) { + if (unixNodeType) { + return "ssh"; + } else { + //Use getSSHExePathInWin(GitClient git), when support for finding ssh executable is provided for agents. + //ssh, will work only if OpenSSH is installed on the system being used to execute the build + return "ssh"; + } + } + + private String getSSHCmd(SSHUserPrivateKey credentials, FilePath tempDir, String sshExePath) { + if (unixNodeType) { + return sshExePath + + " -i " + + getPrivateKeyFile(credentials, tempDir).getRemote() + + " -o StrictHostKeyChecking=no"; + } else { + return "\"" + sshExePath + "\"" + + " -i " + + "\"" + + getPrivateKeyFile(credentials, tempDir).getRemote() + + "\"" + + " -o StrictHostKeyChecking=no"; + } + } + + protected final class SSHScriptFile extends AbstractOnDiskBinding { + + private final String sshExePath; + private final boolean unixNodeType; + + protected SSHScriptFile(SSHUserPrivateKey credentials, String sshExePath, boolean unixNodeType) { + super(SSHKeyUtils.getSinglePrivateKey(credentials) + ":" + SSHKeyUtils.getPassphraseAsString(credentials), credentials.getId()); + this.sshExePath = sshExePath; + this.unixNodeType = unixNodeType; + } + + @Override + protected FilePath write(SSHUserPrivateKey credentials, FilePath workspace) throws IOException, InterruptedException { + FilePath tempFile; + if (unixNodeType) { + tempFile = workspace.createTempFile("gitSSHScript", ".sh"); + tempFile.write( + "ssh -i " + + getPrivateKeyFile(credentials, workspace).getRemote() + + " -o StrictHostKeyChecking=no $@", null); + tempFile.chmod(0500); + } else { + tempFile = workspace.createTempFile("gitSSHScript", ".bat"); + tempFile.write("@echo off\r\n" + + "\"" + + this.sshExePath + + "\"" + + " -i " + + "\"" + + getPrivateKeyFile(credentials, workspace).getRemote() + + "\"" + + " -o StrictHostKeyChecking=no", null); + } + return tempFile; + } + + @Override + protected Class type() { + return SSHUserPrivateKey.class; + } + } + + @Symbol("gitSshPrivateKey") + @Extension + public static final class DescriptorImpl extends BindingDescriptor { + + @NonNull + @Override + public String getDisplayName() { + return Messages.GitSSHPrivateKeyBinding_DisplayName(); + } + + @RequirePOST + public ListBoxModel doFillGitToolNameItems() { + ListBoxModel items = new ListBoxModel(); + List toolList = Jenkins.get().getDescriptorByType(GitSCM.DescriptorImpl.class).getGitTools(); + for (GitTool t : toolList) { + if (t.getClass().equals(GitTool.class)) { + items.add(t.getName()); + } + } + return items; + } + + @Override + protected Class type() { + return SSHUserPrivateKey.class; + } + + @Override + public boolean requiresWorkspace() { + return true; + } + } +} diff --git a/src/main/java/jenkins/plugins/git/OpenSSHKeyFormatImpl.java b/src/main/java/jenkins/plugins/git/OpenSSHKeyFormatImpl.java new file mode 100644 index 0000000000..f5abda52f8 --- /dev/null +++ b/src/main/java/jenkins/plugins/git/OpenSSHKeyFormatImpl.java @@ -0,0 +1,107 @@ +package jenkins.plugins.git; + +import hudson.FilePath; +import hudson.util.Secret; +import org.apache.sshd.common.config.keys.loader.openssh.OpenSSHKeyPairResourceParser; +import org.apache.sshd.common.NamedResource; +import org.apache.sshd.common.config.keys.FilePasswordProvider; +import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter; +import org.apache.sshd.common.session.SessionContext; +import org.apache.sshd.common.util.io.SecureByteArrayOutputStream; + +import javax.naming.SizeLimitExceededException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +public class OpenSSHKeyFormatImpl { + + private final String privateKey; + private final Secret passphrase; + private static final String BEGIN_MARKER = OpenSSHKeyPairResourceParser.BEGIN_MARKER; + private static final String END_MARKER = OpenSSHKeyPairResourceParser.END_MARKER; + private static final String DASH_MARKER = "-----"; + + public OpenSSHKeyFormatImpl(final String privateKey, final Secret passphrase) { + this.privateKey = privateKey; + this.passphrase = passphrase; + } + + private byte[] getEncData(String privateKey){ + String data = privateKey + .replace(DASH_MARKER+BEGIN_MARKER+DASH_MARKER,"") + .replace(DASH_MARKER+END_MARKER+DASH_MARKER,"") + .replaceAll("\\s",""); + Base64.Decoder decoder = Base64.getDecoder(); + return decoder.decode(data); + } + + private KeyPair getOpenSSHKeyPair(SessionContext session, NamedResource resourceKey, + String beginMarker, String endMarker, + FilePasswordProvider passwordProvider, + InputStream stream, Map headers ) + throws IOException, GeneralSecurityException, SizeLimitExceededException { + OpenSSHKeyPairResourceParser openSSHParser = new OpenSSHKeyPairResourceParser(); + Collection keyPairs = openSSHParser.extractKeyPairs(session,resourceKey,beginMarker, + endMarker, passwordProvider, + stream, headers); + if(keyPairs.size() > 1){ + throw new SizeLimitExceededException("Expected KeyPair size to be 1"); + }else { + return Collections.unmodifiableCollection(keyPairs).iterator().next(); + } + } + + private FilePath writePrivateKeyOpenSSHFormatted(FilePath tempFile) throws SizeLimitExceededException, GeneralSecurityException, IOException, InterruptedException { + OpenSSHKeyPairResourceWriter privateKeyWriter = new OpenSSHKeyPairResourceWriter(); + SecureByteArrayOutputStream privateKeyBuffer = new SecureByteArrayOutputStream(); + ByteArrayInputStream stream = new ByteArrayInputStream(getEncData(this.privateKey)); + KeyPair sshKeyPair = null; + sshKeyPair = getOpenSSHKeyPair(null,null,"","", + new AcquirePassphrase(this.passphrase), + stream,null); + privateKeyWriter.writePrivateKey(sshKeyPair, "", null, privateKeyBuffer); + tempFile.write(privateKeyBuffer.toString(),null); + return tempFile; + } + + /** + * Check the format of private key using HEADERS + * @param privateKey The private key{@link java.lang.String} + * @return true is the privater key is in OpenSSH format + **/ + public static boolean isOpenSSHFormatted(String privateKey) { + final String HEADER = DASH_MARKER+BEGIN_MARKER+DASH_MARKER; + return privateKey.regionMatches(false, 0, HEADER, 0, HEADER.length()); + } + + /** + * Decrypts the passphrase protected OpenSSH formatted private key + * @param tempKeyFile Decrypted private key file{@link hudson.FilePath} on agents/controller file-system + * @return Decrypted private key file{@link hudson.FilePath} OpenSSH Formatted + **/ + public FilePath writeDecryptedOpenSSHKey(FilePath tempKeyFile) throws IOException, InterruptedException, GeneralSecurityException, SizeLimitExceededException { + return writePrivateKeyOpenSSHFormatted(tempKeyFile); + } + + private final static class AcquirePassphrase implements FilePasswordProvider { + + Secret passphrase; + + AcquirePassphrase(Secret passphrase) { + this.passphrase = passphrase; + } + + @Override + public String getPassword(SessionContext session, NamedResource resourceKey, int retryIndex) throws IOException { + return this.passphrase.getPlainText(); + } + } +} diff --git a/src/main/java/jenkins/plugins/git/SSHKeyUtils.java b/src/main/java/jenkins/plugins/git/SSHKeyUtils.java new file mode 100644 index 0000000000..8248513b21 --- /dev/null +++ b/src/main/java/jenkins/plugins/git/SSHKeyUtils.java @@ -0,0 +1,93 @@ +package jenkins.plugins.git; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.FilePath; +import hudson.util.Secret; +import jenkins.bouncycastle.api.PEMEncodable; +import org.jenkinsci.plugins.gitclient.CliGitAPIImpl; +import org.jenkinsci.plugins.gitclient.GitClient; + +import javax.naming.SizeLimitExceededException; +import java.io.IOException; +import java.security.GeneralSecurityException; + +public interface SSHKeyUtils { + + /** + * Get a single private key + * @param credentials Credentials{@link com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey}. Can't be null + * @return Private key at index 0 + * @exception IndexOutOfBoundsException thrown when no private key if found/available + **/ + static String getSinglePrivateKey(@NonNull SSHUserPrivateKey credentials) { + return credentials.getPrivateKeys().get(0); + } + + /** + * Get passphrase as a Secret{@link hudson.util.Secret} + * @param credentials Credentials{@link com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey}. Can't be null + * @return Passphrase of type secret{@link hudson.util.Secret} + **/ + static Secret getPassphraseAsSecret(@NonNull SSHUserPrivateKey credentials) { + return credentials.getPassphrase(); + } + + /** + * Get passphrase as a String{@link java.lang.String} + * @param credentials Credentials{@link com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey}. Can't be null + * @return Passphrase of type string{@link java.lang.String} + **/ + static String getPassphraseAsString(@NonNull SSHUserPrivateKey credentials) { + return Secret.toString(credentials.getPassphrase()); + } + + /** + * Check if private key is encrypted by using the passphrase + * @param passphrase Passphrase{@link java.lang.String}. Can't be null + * @return True if passphrase is not an empty string value + **/ + static boolean isPrivateKeyEncrypted(@NonNull String passphrase) { + return passphrase.isEmpty() ? false : true; + } + + /** + * Get SSH executable absolute path{@link java.lang.String} on a Windows system + * @param git Git Client{@link org.jenkinsci.plugins.gitclient.GitClient}. Can't be null + * @return SSH executable absolute path{@link java.lang.String} + * @exception InterruptedException If no ssh executable path is found + **/ + default String getSSHExePathInWin(@NonNull GitClient git) throws IOException, InterruptedException { + return ((CliGitAPIImpl) git).getSSHExecutable().getAbsolutePath(); + } + + /** + * Get a file{@link hudson.FilePath} stored on the file-system used during the build, with private key written in it. + * The private key if encrypted will be decrypted first and then written in the file{@link hudson.FilePath}. + * @param credentials Credentials{@link com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey}. Can't be null + * @param workspace Work directory{@link hudson.FilePath} for storing private key file + * @return Private Key file + **/ + default FilePath getPrivateKeyFile(@NonNull SSHUserPrivateKey credentials, @NonNull FilePath workspace) { + final String privateKeyValue = SSHKeyUtils.getSinglePrivateKey(credentials); + final String passphraseValue = SSHKeyUtils.getPassphraseAsString(credentials); + try { + FilePath tempKeyFile = workspace.createTempFile("private", ".key"); + if (isPrivateKeyEncrypted(passphraseValue)) { + if (OpenSSHKeyFormatImpl.isOpenSSHFormatted(privateKeyValue)) { + OpenSSHKeyFormatImpl openSSHKeyFormat = new OpenSSHKeyFormatImpl(privateKeyValue, SSHKeyUtils.getPassphraseAsSecret(credentials)); + openSSHKeyFormat.writeDecryptedOpenSSHKey(tempKeyFile); + } else { + tempKeyFile.write(PEMEncodable.decode(privateKeyValue, passphraseValue.toCharArray()).encode(), null); + } + } else { + tempKeyFile.write(privateKeyValue, null); + } + tempKeyFile.chmod(0400); + return tempKeyFile; + } catch (IOException | InterruptedException | GeneralSecurityException | SizeLimitExceededException e) { + e.printStackTrace(); + } + return null; + } +} diff --git a/src/main/resources/jenkins/plugins/git/GitSSHPrivateKeyBinding/config.jelly b/src/main/resources/jenkins/plugins/git/GitSSHPrivateKeyBinding/config.jelly new file mode 100644 index 0000000000..60358eb3fc --- /dev/null +++ b/src/main/resources/jenkins/plugins/git/GitSSHPrivateKeyBinding/config.jelly @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/main/resources/jenkins/plugins/git/GitSSHPrivateKeyBinding/help-credentialsId.html b/src/main/resources/jenkins/plugins/git/GitSSHPrivateKeyBinding/help-credentialsId.html new file mode 100644 index 0000000000..9051124164 --- /dev/null +++ b/src/main/resources/jenkins/plugins/git/GitSSHPrivateKeyBinding/help-credentialsId.html @@ -0,0 +1,31 @@ +
+ Set the git private key for SSH protocol. + +

+Shell example +

+withCredentials([gitSshPrivateKey(credentialsId: 'my-credentials-id', gitToolName: 'git-tool')]) {
+  sh 'git fetch --all'
+}
+
+

+ +

+Batch example +

+withCredentials([gitSshPrivateKey(credentialsId: 'my-credentials-id', gitToolName: 'git-tool')]) {
+  bat 'git submodule update --init --recursive'
+}
+
+

+ +

+Powershell example +

+withCredentials([gitSshPrivateKey(credentialsId: 'my-credentials-id', gitToolName: 'git-tool')]) {
+  powershell 'git push'
+}
+
+

+ +
diff --git a/src/main/resources/jenkins/plugins/git/GitSSHPrivateKeyBinding/help-gitToolName.html b/src/main/resources/jenkins/plugins/git/GitSSHPrivateKeyBinding/help-gitToolName.html new file mode 100644 index 0000000000..c216d2c77c --- /dev/null +++ b/src/main/resources/jenkins/plugins/git/GitSSHPrivateKeyBinding/help-gitToolName.html @@ -0,0 +1,5 @@ +
+

+ Specify the Git tool installation name +

+
diff --git a/src/main/resources/jenkins/plugins/git/Messages.properties b/src/main/resources/jenkins/plugins/git/Messages.properties index eeb968c080..8a22bf9544 100644 --- a/src/main/resources/jenkins/plugins/git/Messages.properties +++ b/src/main/resources/jenkins/plugins/git/Messages.properties @@ -26,3 +26,4 @@ GitStep.git=Git within.Repository=Within Repository additional=Additional GitUsernamePasswordBinding.DisplayName=Git Username and Password +GitSSHPrivateKeyBinding.DisplayName=Git SSH Private Key diff --git a/src/test/java/jenkins/plugins/git/GitSSHPrivateKeyBindingTest.java b/src/test/java/jenkins/plugins/git/GitSSHPrivateKeyBindingTest.java new file mode 100644 index 0000000000..d8aaa32a28 --- /dev/null +++ b/src/test/java/jenkins/plugins/git/GitSSHPrivateKeyBindingTest.java @@ -0,0 +1,193 @@ +package jenkins.plugins.git; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.domains.Domain; +import hudson.model.FreeStyleBuild; +import hudson.model.FreeStyleProject; +import hudson.model.Item; +import hudson.plugins.git.GitTool; +import hudson.tasks.BatchFile; +import hudson.tasks.Shell; +import jenkins.model.Jenkins; +import org.apache.commons.codec.digest.DigestUtils; +import org.jenkinsci.plugins.credentialsbinding.MultiBinding; +import org.jenkinsci.plugins.credentialsbinding.impl.SecretBuildWrapper; +import org.jenkinsci.plugins.gitclient.JGitApacheTool; +import org.jenkinsci.plugins.gitclient.JGitTool; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.jvnet.hudson.test.JenkinsRule; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static java.util.zip.ZipFile.OPEN_READ; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.containsString; + +@RunWith(Parameterized.class) +public class GitSSHPrivateKeyBindingTest { + @Parameterized.Parameters(name = "PrivateKey-Name {0}: Passphrase {1}: GitToolInstance {2}") + public static Collection data() throws IOException { + return Arrays.asList(new Object[][]{ + //algorithm_format_enc/uenc + {privateKeyFileRead("rsa_openssh_enc"), "Dummy", new GitTool("git", "git", null)}, + {privateKeyFileRead("rsa_openssh_uenc"), "", new GitTool("Default", "git", null)}, + {privateKeyFileRead("rsa_openssh_enc"), "Dummy", new JGitTool()}, + {privateKeyFileRead("rsa_openssh_uenc"), "Dummy", new JGitApacheTool()}, + }); + } + + private final String privateKey; + private final String privatekeyPassphrase; + private final GitTool gitToolInstance; + private final String credentialID = DigestUtils.sha256Hex(("Git SSH Private Key Binding").getBytes(StandardCharsets.UTF_8)); + + private SSHUserPrivateKey credentials = null; + private BasicSSHUserPrivateKey.DirectEntryPrivateKeySource privateKeySource = null; + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Rule + public GitSampleRepoRule g = new GitSampleRepoRule(); + + public GitSSHPrivateKeyBindingTest(String privateKey, String passphrase, GitTool gitToolInstance){ + this.privateKey = privateKey; + this.privatekeyPassphrase = passphrase; + this.gitToolInstance = gitToolInstance; + } + + private static String privateKeyFileRead(String privatekeyFilename) throws IOException { + File zipFilePath = new File("src/test/resources/jenkins/plugins/git/GitSSHPrivateKeyBindingTest/sshKeys.zip"); + ZipFile zipFile = new ZipFile(zipFilePath,OPEN_READ); + ZipEntry zipEntry = zipFile.getEntry(privatekeyFilename); + InputStream stream = zipFile.getInputStream(zipEntry); + int arraySize = (int) zipEntry.getSize(); + byte[] keyBytes = new byte[arraySize]; + stream.read(keyBytes); + stream.close(); + zipFile.close(); + return new String(keyBytes, StandardCharsets.UTF_8); + } + + @Before + public void basicSetup() throws IOException { + //Private Key + privateKeySource = new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(privateKey); + + //Credential init + credentials = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialID, "GitSSHPrivateKey" + ,privateKeySource,privatekeyPassphrase,"Git SSH Private Key Binding"); + CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), credentials); + + //Setting Git Tool + Jenkins.get().getDescriptorByType(GitTool.DescriptorImpl.class).getDefaultInstallers().clear(); + Jenkins.get().getDescriptorByType(GitTool.DescriptorImpl.class).setInstallations(gitToolInstance); + } + + private String batchCheck(boolean includeCliCheck) { + return includeCliCheck + ? "set | findstr GCM_INTERACTIVE >> sshAuth.txt" + : "set > sshAuth.txt"; + } + + private String shellCheck() { + return "env | grep -zE \"GIT_TERMINAL_PROMPT|\" > sshAuth.txt"; + } + + @Test + public void test_SSHBinding_FreeStyleProject() throws Exception { + FreeStyleProject prj = r.createFreeStyleProject(); + prj.getBuildWrappersList().add(new SecretBuildWrapper(Collections.> + singletonList(new GitSSHPrivateKeyBinding(gitToolInstance.getName(),credentialID)))); + prj.getBuildersList().add(isWindows() ? new BatchFile(batchCheck(isCliGitTool())) : new Shell(shellCheck())); + r.configRoundtrip((Item) prj); + + SecretBuildWrapper wrapper = prj.getBuildWrappersList().get(SecretBuildWrapper.class); + assertThat(wrapper, is(notNullValue())); + List> bindings = wrapper.getBindings(); + assertThat(bindings.size(), is(1)); + MultiBinding binding = bindings.get(0); + if(isCliGitTool()) { + assertThat(((GitSSHPrivateKeyBinding) binding).getGitToolName(), equalTo(gitToolInstance.getName())); + }else { + assertThat(((GitSSHPrivateKeyBinding) binding).getGitToolName(), equalTo("")); + } + + FreeStyleBuild b = r.buildAndAssertSuccess(prj); + + String fileContents = b.getWorkspace().child("sshAuth.txt").readToString().trim(); + //Assert Git specific env variables + if (isCliGitTool()) { + if (isWindows()) { + assertThat(fileContents, containsString("GCM_INTERACTIVE=false")); + } else if (g.gitVersionAtLeast(2, 3, 0)) { + assertThat(fileContents, containsString("GIT_TERMINAL_PROMPT=false")); + } + } + } + + @Test + public void test_SSHBinding_PipelineJob() throws Exception { + WorkflowJob project = r.createProject(WorkflowJob.class); + + // Use default tool if JGit or JGitApache + String gitToolNameArg = !isCliGitTool() ? "" : ", gitToolName: '" + gitToolInstance.getName() + "'"; + + String pipeline = "" + + "node {\n" + + " withCredentials([gitSshPrivateKey(credentialsId: '" + credentialID + "'" + gitToolNameArg + ")]) {\n" + + " if (isUnix()) {\n" + + " sh '" + shellCheck() + "'\n" + + " } else {\n" + + " bat '" + batchCheck(isCliGitTool()) + "'\n" + + " }\n" + + " }\n" + + "}"; + project.setDefinition(new CpsFlowDefinition(pipeline, true)); + WorkflowRun b = project.scheduleBuild2(0).waitForStart(); + r.waitForCompletion(b); + r.assertBuildStatusSuccess(b); + + String fileContents = r.jenkins.getWorkspaceFor(project).child("sshAuth.txt").readToString().trim(); + // Assert Git version specific env variables + if (isCliGitTool()) { + if (isWindows()) { + assertThat(fileContents, containsString("GCM_INTERACTIVE=false")); + } else if (g.gitVersionAtLeast(2, 3, 0)) { + assertThat(fileContents, containsString("GIT_TERMINAL_PROMPT=false")); + } + } + } + + /** + * inline ${@link hudson.Functions#isWindows()} to prevent a transient + * remote classloader issue + */ + private static boolean isWindows() { + return File.pathSeparatorChar == ';'; + } + + private boolean isCliGitTool() { + return gitToolInstance.getClass().equals(GitTool.class); + } +} diff --git a/src/test/java/jenkins/plugins/git/SSHKeyUtilsTest.java b/src/test/java/jenkins/plugins/git/SSHKeyUtilsTest.java new file mode 100644 index 0000000000..46752f482f --- /dev/null +++ b/src/test/java/jenkins/plugins/git/SSHKeyUtilsTest.java @@ -0,0 +1,114 @@ +package jenkins.plugins.git; + +import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.domains.Domain; +import hudson.plugins.git.GitTool; +import hudson.util.Secret; +import jenkins.model.Jenkins; +import org.apache.commons.codec.digest.DigestUtils; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.jvnet.hudson.test.JenkinsRule; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static java.util.zip.ZipFile.OPEN_READ; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +@RunWith(Parameterized.class) +public class SSHKeyUtilsTest { + @Parameterized.Parameters(name = "PrivateKey-Name {0}: Passphrase {1}") + public static Collection data() throws IOException { + return Arrays.asList(new Object[][]{ + //algorithm_format_enc/uenc + {privateKeyFileRead("rsa_openssh_enc"), "Dummy"}, + {privateKeyFileRead("rsa_openssh_uenc"), ""}, + }); + } + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Rule + public GitSampleRepoRule g = new GitSampleRepoRule(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private final String privateKey; + private final String privatekeyPassphrase; + private final String credentialID = DigestUtils.sha256Hex(("Git SSH Private Key Binding").getBytes(StandardCharsets.UTF_8)); + + private SSHUserPrivateKey credentials = null; + private BasicSSHUserPrivateKey.DirectEntryPrivateKeySource privateKeySource = null; + private File workspace = null; + + + public SSHKeyUtilsTest(String privateKey, String passphrase) throws IOException { + this.privateKey = privateKey; + this.privatekeyPassphrase = passphrase; + } + + private static String privateKeyFileRead(String privatekeyFilename) throws IOException { + File zipFilePath = new File("src/test/resources/jenkins/plugins/git/GitSSHPrivateKeyBindingTest/sshKeys.zip"); + ZipFile zipFile = new ZipFile(zipFilePath,OPEN_READ); + ZipEntry zipEntry = zipFile.getEntry(privatekeyFilename); + InputStream stream = zipFile.getInputStream(zipEntry); + int arraySize = (int) zipEntry.getSize(); + byte[] keyBytes = new byte[arraySize]; + stream.read(keyBytes); + stream.close(); + zipFile.close(); + return new String(keyBytes, StandardCharsets.UTF_8); + } + + @Before + public void basicSetup() throws IOException { + //Create Folder + workspace = temporaryFolder.newFolder(); + + //Private Key + privateKeySource = new BasicSSHUserPrivateKey.DirectEntryPrivateKeySource(privateKey); + + //Credential init + credentials = new BasicSSHUserPrivateKey(CredentialsScope.GLOBAL, credentialID, "GitSSHPrivateKey" + ,privateKeySource,privatekeyPassphrase,"Git SSH Private Key Binding"); + CredentialsProvider.lookupStores(r.jenkins).iterator().next().addCredentials(Domain.global(), credentials); + } + + @Test + public void test_SSHKeyUtils_StaticMethods(){ + String tempKey = SSHKeyUtils.getSinglePrivateKey(credentials); + assertThat(tempKey, equalTo(this.privateKey)); + String tempPassphraseString = SSHKeyUtils.getPassphraseAsString(credentials); + assertThat(tempPassphraseString, equalTo(this.privatekeyPassphrase)); + Secret tempPassphraseSecret = SSHKeyUtils.getPassphraseAsSecret(credentials); + assertThat(tempPassphraseSecret, equalTo(this.credentials.getPassphrase())); + boolean flag = SSHKeyUtils.isPrivateKeyEncrypted(this.privatekeyPassphrase); + assertThat(flag, not(this.privatekeyPassphrase.isEmpty())); + } + + private GitSSHPrivateKeyBinding getGitSSHBindingInstance() { + //Setting Git Tool + Jenkins.get().getDescriptorByType(GitTool.DescriptorImpl.class).getDefaultInstallers().clear(); + Jenkins.get().getDescriptorByType(GitTool.DescriptorImpl.class).setInstallations(new GitTool("Default", "git", null)); + return new GitSSHPrivateKeyBinding("Default",credentialID); + } +} diff --git a/src/test/resources/jenkins/plugins/git/GitSSHPrivateKeyBindingTest/sshKeys.zip b/src/test/resources/jenkins/plugins/git/GitSSHPrivateKeyBindingTest/sshKeys.zip new file mode 100644 index 0000000000..cedb3d965e Binary files /dev/null and b/src/test/resources/jenkins/plugins/git/GitSSHPrivateKeyBindingTest/sshKeys.zip differ