diff --git a/pom.xml b/pom.xml index b73040a64..8d4beaf91 100644 --- a/pom.xml +++ b/pom.xml @@ -161,6 +161,33 @@ workflow-basic-steps test + + org.jenkins-ci.plugins + job-dsl + 1.81 + test + + + org.jenkins-ci.plugins + command-launcher + 1.6 + test + + + org.jenkins-ci.plugins + credentials-binding + 523.vd859a_4b_122e6 + test + + + org.jenkins-ci.plugins + http_request + + 1.16 + test + diff --git a/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl.java b/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl.java index a92d84f48..ad9b9dbd6 100644 --- a/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl.java +++ b/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl.java @@ -23,6 +23,7 @@ */ package com.cloudbees.plugins.credentials.impl; +import com.cloudbees.plugins.credentials.CredentialsProvider; import com.cloudbees.plugins.credentials.CredentialsScope; import com.cloudbees.plugins.credentials.SecretBytes; import com.cloudbees.plugins.credentials.common.StandardCertificateCredentials; @@ -34,6 +35,7 @@ import hudson.model.AbstractDescribableImpl; import hudson.model.Descriptor; import hudson.model.Items; +import hudson.remoting.Channel; import hudson.util.FormValidation; import hudson.util.IOUtils; import hudson.util.Secret; @@ -138,6 +140,21 @@ private static char[] toCharArray(@NonNull Secret password) { return plainText == null ? null : plainText.toCharArray(); } + /** + * When serializing over a {@link Channel} ensure that we send a self-contained version. + * + * @return the object instance to write to the stream. + */ + private Object writeReplace() { + if (/* XStream */ Channel.current() == null + || /* already safe to serialize */ keyStoreSource + .isSnapshotSource() + ) { + return this; + } + return CredentialsProvider.snapshot(this); + } + /** * Returns the {@link KeyStore} containing the certificate. * @@ -419,17 +436,18 @@ public static class UploadedKeyStoreSource extends KeyStoreSource implements Ser /** * The old uploaded keystore. + * Still used for snapshot taking, with contents independent of Jenkins instance and JVM. */ @CheckForNull @Deprecated - private transient Secret uploadedKeystore; + private Secret uploadedKeystore; /** * The uploaded keystore. * * @since 2.1.5 */ @CheckForNull - private final SecretBytes uploadedKeystoreBytes; + private SecretBytes uploadedKeystoreBytes; /** * Our constructor. @@ -457,6 +475,19 @@ public UploadedKeyStoreSource(@CheckForNull SecretBytes uploadedKeystore) { this.uploadedKeystoreBytes = uploadedKeystore; } + /** + * Our constructor for serialization (e.g. to remote agents, whose SecretBytes + * in another JVM use a different static KEY); would re-encode. + * + * @param uploadedKeystore the keystore content. + * @deprecated + */ + @SuppressWarnings("unused") // by stapler + @Deprecated + public UploadedKeyStoreSource(@CheckForNull Secret uploadedKeystore) { + this.uploadedKeystore = uploadedKeystore; + } + /** * Constructor able to receive file directly * @@ -475,6 +506,18 @@ public UploadedKeyStoreSource(FileItem uploadedCertFile, @CheckForNull SecretByt this.uploadedKeystoreBytes = uploadedKeystore; } + /** + * Request that if the less-efficient but more-portable Secret + * is involved (e.g. to cross the remoting gap to another JVM), + * we use the more secure and efficient SecretBytes. + */ + public void useSecretBytes() { + if (this.uploadedKeystore != null && this.uploadedKeystoreBytes == null) { + this.uploadedKeystoreBytes = SecretBytes.fromBytes(DescriptorImpl.toByteArray(this.uploadedKeystore)); + this.uploadedKeystore = null; + } + } + /** * Migrate to the new field. * @@ -490,11 +533,14 @@ private Object readResolve() throws ObjectStreamException { } /** - * Returns the private key file name. + * Returns the private key + certificate file bytes. * - * @return the private key file name. + * @return the private key + certificate file bytes. */ public SecretBytes getUploadedKeystore() { + if (uploadedKeystore != null && uploadedKeystoreBytes == null) { + return SecretBytes.fromBytes(DescriptorImpl.toByteArray(uploadedKeystore)); + } return uploadedKeystoreBytes; } @@ -504,6 +550,9 @@ public SecretBytes getUploadedKeystore() { @NonNull @Override public byte[] getKeyStoreBytes() { + if (uploadedKeystore != null && uploadedKeystoreBytes == null) { + return DescriptorImpl.toByteArray(uploadedKeystore); + } return SecretBytes.getPlainData(uploadedKeystoreBytes); } @@ -520,7 +569,11 @@ public long getKeyStoreLastModified() { */ @Override public boolean isSnapshotSource() { - return true; + //return this.snapshotSecretBytes; + // If context is local, clone SecretBytes directly (only + // usable in same JVM). Otherwise use Secret for transport + // (see {@link CertificateCredentialsSnapshotTaker}. + return (/* XStream */ Channel.current() == null); } /** diff --git a/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsSnapshotTaker.java b/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsSnapshotTaker.java new file mode 100644 index 000000000..90697d7e5 --- /dev/null +++ b/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsSnapshotTaker.java @@ -0,0 +1,97 @@ +/* + * The MIT License + * + * Copyright (c) 2011-2016, CloudBees, Inc., Stephen Connolly. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + + +package com.cloudbees.plugins.credentials.impl; + +import com.cloudbees.plugins.credentials.CredentialsSnapshotTaker; +import com.cloudbees.plugins.credentials.SecretBytes; +import com.cloudbees.plugins.credentials.common.StandardCertificateCredentials; +import hudson.Extension; +import hudson.util.Secret; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.Arrays; + +/** + * The {@link CredentialsSnapshotTaker} for {@link StandardCertificateCredentials}. + * Taking a snapshot of the credential ensures that all the details are captured + * within the credential. + * + * @since 1.14 + * + * Historic note: This code was dropped from {@link CertificateCredentialsImpl} + * codebase along with most of FileOnMasterKeyStoreSource (deprecated and headed + * towards eventual deletion) due to SECURITY-1322, see more details at + * https://www.jenkins.io/security/advisory/2019-05-21/ + * In hind-sight, this snapshot taker was needed to let the + * {@link CertificateCredentialsImpl.UploadedKeyStoreSource} be used + * on remote agents. + */ +@Extension +public class CertificateCredentialsSnapshotTaker extends CredentialsSnapshotTaker { + + /** + * {@inheritDoc} + */ + @Override + public Class type() { + return StandardCertificateCredentials.class; + } + + /** + * {@inheritDoc} + */ + @Override + public StandardCertificateCredentials snapshot(StandardCertificateCredentials credentials) { + if (credentials instanceof CertificateCredentialsImpl) { + final CertificateCredentialsImpl.KeyStoreSource keyStoreSource = ((CertificateCredentialsImpl) credentials).getKeyStoreSource(); + if (keyStoreSource.isSnapshotSource()) { + return credentials; + } + return new CertificateCredentialsImpl(credentials.getScope(), credentials.getId(), + credentials.getDescription(), credentials.getPassword().getEncryptedValue(), + new CertificateCredentialsImpl.UploadedKeyStoreSource(CertificateCredentialsImpl.UploadedKeyStoreSource.DescriptorImpl.toSecret(keyStoreSource.getKeyStoreBytes()))); + } + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + final char[] password = credentials.getPassword().getPlainText().toCharArray(); + try { + credentials.getKeyStore().store(bos, password); + bos.close(); + } catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException e) { + return credentials; // as-is + } finally { + Arrays.fill(password, (char) 0); + } + + return new CertificateCredentialsImpl(credentials.getScope(), credentials.getId(), + credentials.getDescription(), credentials.getPassword().getEncryptedValue(), + new CertificateCredentialsImpl.UploadedKeyStoreSource(CertificateCredentialsImpl.UploadedKeyStoreSource.DescriptorImpl.toSecret(bos.toByteArray()))); + } +} diff --git a/src/test/java/com/cloudbees/plugins/credentials/CredentialsInPipelineTest.java b/src/test/java/com/cloudbees/plugins/credentials/CredentialsInPipelineTest.java new file mode 100644 index 000000000..579318a7a --- /dev/null +++ b/src/test/java/com/cloudbees/plugins/credentials/CredentialsInPipelineTest.java @@ -0,0 +1,702 @@ +/* + * The MIT License + * + * Copyright 2022 Jim Klimov. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.cloudbees.plugins.credentials; + +import com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl; +import com.cloudbees.plugins.credentials.impl.CertificateCredentialsImplTest; +import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl; +import hudson.model.Descriptor; +import hudson.model.Node; +import hudson.model.Result; +import hudson.model.Slave; +import hudson.slaves.CommandLauncher; +import hudson.slaves.ComputerLauncher; +import hudson.slaves.DumbSlave; +import hudson.slaves.RetentionStrategy; +import org.apache.commons.io.FileUtils; +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.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assume.assumeThat; + +public class CredentialsInPipelineTest { + /** + * The CredentialsInPipelineTest suite prepares pipeline scripts to + * retrieve some previously saved credentials, on the controller, + * on a node provided by it, and on a worker agent in separate JVM. + * This picks known-working test cases and their setup from other + * test classes which address those credential types in more detail. + * Initially tied to JENKINS-70101 research. + */ + + // For developers: set to `true` so that pipeline console logs show + // up in System.out (and/or System.err) of the plugin test run by + // mvn test -Dtest="CredentialsInPipelineTest" + private boolean verbosePipelines = false; + + @Rule + public JenkinsRule r = new JenkinsRule(); + + // Data for build agent setup + @Rule + public TemporaryFolder tmpAgent = new TemporaryFolder(); + @Rule + public TemporaryFolder tmpWorker = new TemporaryFolder(); + // Where did we save that file?.. + private File agentJar = null; + // Can this be reused for many test cases? + private Slave agent = null; + // Unknown/started/not usable + private Boolean agentUsable = null; + + // From CertificateCredentialImplTest + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + private File p12; + + @Before + public void setup() { + r.jenkins.setCrumbIssuer(null); + } + + Boolean isAvailableAgent() { + // Can be used to skip optional tests if we know we could not set up an agent + if (agentJar == null) + return false; + if (agent == null) + return false; + return agentUsable; + } + + Boolean setupAgent() throws IOException, InterruptedException, OutOfMemoryError { + // Note we anticipate this might fail; it should not block the whole test suite from running + // Loosely inspired by + // https://docs.cloudbees.com/docs/cloudbees-ci-kb/latest/client-and-managed-masters/create-agent-node-from-groovy + + // Is it known-impossible to start the agent? + if (agentUsable != null && agentUsable == false) + return agentUsable; // quickly for re-runs + + // Did we download this file for earlier test cases? + if (agentJar == null) { + try { + URL url = new URL(r.jenkins.getRootUrl() + "jnlpJars/agent.jar"); + agentJar = tmpAgent.newFile("agent.jar"); + FileOutputStream out = new FileOutputStream(agentJar); + out.write(url.openStream().readAllBytes()); + out.close(); + } catch (IOException | OutOfMemoryError e) { + agentJar = null; + agentUsable = false; + + System.out.println("Failed to download agent.jar from test instance: " + + e.toString()); + + return agentUsable; + } + } + + // This CLI spelling and quoting should play well with both Windows + // (including spaces in directory names) and Unix/Linux + ComputerLauncher launcher = new CommandLauncher( + "\"" + System.getProperty("java.home") + File.separator + "bin" + + File.separator + "java\" -jar \"" + agentJar.getAbsolutePath().toString() + "\"" + ); + + try { + // Define a "Permanent Agent" + agent = new DumbSlave( + "worker", + tmpWorker.getRoot().getAbsolutePath().toString(), + launcher); + agent.setNodeDescription("Worker in another JVM, remoting used"); + agent.setNumExecutors(1); + agent.setLabelString("worker"); + agent.setMode(Node.Mode.EXCLUSIVE); + agent.setRetentionStrategy(new RetentionStrategy.Always()); + +/* + // Add node envvars + List env = new ArrayList(); + env.add(new Entry("key1","value1")); + env.add(new Entry("key2","value2")); + EnvironmentVariablesNodeProperty envPro = new EnvironmentVariablesNodeProperty(env); + agent.getNodeProperties().add(envPro); +*/ + + r.jenkins.addNode(agent); + + String agentLog = null; + agentUsable = false; + for (long i = 0; i < 5; i++) { + Thread.sleep(1000); + agentLog = agent.getComputer().getLog(); + if (i == 2 && (agentLog == null || agentLog.isEmpty())) { + // Give it a little time to autostart, then kick it up if needed: + agent.getComputer().connect(true); // "always" should have started it; avoid duplicate runs + } + if (agentLog != null && agentLog.contains("Agent successfully connected and online")) { + agentUsable = true; + break; + } + } + System.out.println("Spawned build agent " + + "usability: " + agentUsable.toString() + + "; connection log:" + (agentLog == null ? " " : "\n" + agentLog)); + } catch (Descriptor.FormException | NullPointerException e) { + agentUsable = false; + } + + return agentUsable; + } + + String getLogAsStringPlaintext(WorkflowRun f) throws java.io.IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + f.getLogText().writeLogTo(0, baos); + return baos.toString(); + } + + ///////////////////////////////////////////////////////////////// + // Certificate credentials tests + ///////////////////////////////////////////////////////////////// + + // Partially from CertificateCredentialImplTest setup() + private void prepareUploadedKeystore() throws IOException { + prepareUploadedKeystore("myCert", "password"); + } + + private void prepareUploadedKeystore(String id, String password) throws IOException { + if (p12 == null) { + // Contains a private key + openvpn certs, + // as alias named "1" (according to keytool) + p12 = tmp.newFile("test.p12"); + FileUtils.copyURLToFile(CertificateCredentialsImplTest.class.getResource("test.p12"), p12); + } + + SecretBytes uploadedKeystore = SecretBytes.fromBytes(Files.readAllBytes(p12.toPath())); + CertificateCredentialsImpl.UploadedKeyStoreSource storeSource = new CertificateCredentialsImpl.UploadedKeyStoreSource(uploadedKeystore); + CertificateCredentialsImpl credentials = new CertificateCredentialsImpl(null, id, null, password, storeSource); + SystemCredentialsProvider.getInstance().getCredentials().add(credentials); + SystemCredentialsProvider.getInstance().save(); + } + + String cpsScriptCredentialTestImports() { + return "import com.cloudbees.plugins.credentials.CredentialsMatchers;\n" + + "import com.cloudbees.plugins.credentials.CredentialsProvider;\n" + + "import com.cloudbees.plugins.credentials.common.StandardCertificateCredentials;\n" + + "import com.cloudbees.plugins.credentials.common.StandardCredentials;\n" + + "import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;\n" + + "import com.cloudbees.plugins.credentials.domains.URIRequirementBuilder;\n" + + "import com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl;\n" + + "import com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl.KeyStoreSource;\n" + + "import hudson.security.ACL;\n" + + "import java.security.KeyStore;\n" + + "\n"; + } + + ///////////////////////////////////////////////////////////////// + // Certificate credentials retrievability in (trusted) pipeline + ///////////////////////////////////////////////////////////////// + + String cpsScriptCertCredentialTestScriptedPipeline(String runnerTag) { + return cpsScriptCertCredentialTestScriptedPipeline("myCert", "password", "1", runnerTag); + } + + String cpsScriptCertCredentialTestScriptedPipeline(String id, String password, String alias, String runnerTag) { + return "def authentication='" + id + "';\n" + + "def password='" + password + "';\n" + + "def alias='" + alias + "';\n" + + "StandardCredentials credential = CredentialsMatchers.firstOrNull(\n" + + " CredentialsProvider.lookupCredentials(\n" + + " StandardCredentials.class,\n" + + " Jenkins.instance, null, null),\n" + + " CredentialsMatchers.withId(authentication));\n" + + "StandardCredentials credentialSnap = CredentialsProvider.snapshot(credential);\n\n" + + "\n" + + "echo \"CRED ON " + runnerTag + ":\"\n" + + "echo credential.toString()\n" + + "KeyStore keyStore = credential.getKeyStore();\n" + + "KeyStoreSource kss = ((CertificateCredentialsImpl) credential).getKeyStoreSource();\n" + + "echo \"KSS: \" + kss.toString()\n" + + "byte[] kssb = kss.getKeyStoreBytes();\n" + + "echo \"KSS bytes (len): \" + kssb.length\n" + + "String keyValue = keyStore.getKey(alias, password.toCharArray()).getEncoded().encodeBase64().toString()\n" + + "echo \"-----BEGIN PRIVATE KEY-----\"\n" + + "echo keyValue\n" + + "echo \"-----END PRIVATE KEY-----\"\n" + + "\n" + + "echo \"CRED-SNAP ON " + runnerTag + ":\"\n" + + "echo credentialSnap.toString()\n" + + "KeyStore keyStoreSnap = credentialSnap.getKeyStore();\n" + + "KeyStoreSource kssSnap = ((CertificateCredentialsImpl) credentialSnap).getKeyStoreSource();\n" + + "echo \"KSS-SNAP: \" + kssSnap.toString()\n" + + "byte[] kssbSnap = kssSnap.getKeyStoreBytes();\n" + + "echo \"KSS-SNAP bytes (len): \" + kssbSnap.length\n" + + "String keyValueSnap = keyStoreSnap.getKey(alias, password.toCharArray()).getEncoded().encodeBase64().toString()\n" + + "echo \"-----BEGIN PRIVATE KEY-----\"\n" + + "echo keyValueSnap\n" + + "echo \"-----END PRIVATE KEY-----\"\n" + + "\n"; + } + + @Test + @Issue("JENKINS-70101") + public void testCertKeyStoreReadableOnController() throws Exception { + // Check that credentials are usable with pipeline script + // running without a node{} + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + cpsScriptCertCredentialTestScriptedPipeline("CONTROLLER BUILT-IN"); + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("KSS-SNAP bytes", run); + } + + @Test + @Issue("JENKINS-70101") + public void testCertKeyStoreReadableOnNodeLocal() throws Exception { + // Check that credentials are usable with pipeline script + // running on a node{} (provided by the controller) + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + "node {\n" + + cpsScriptCertCredentialTestScriptedPipeline("CONTROLLER NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("KSS-SNAP bytes", run); + } + + @Test + @Issue("JENKINS-70101") + public void testCertKeyStoreReadableOnNodeRemote() throws Exception { + // Check that credentials are usable with pipeline script + // running on a remote node{} with separate JVM (check + // that remoting/snapshot work properly) + assumeThat("This test needs a separate build agent", this.setupAgent(), is(true)); + + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + "node(\"worker\") {\n" + + cpsScriptCertCredentialTestScriptedPipeline("REMOTE NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("KSS-SNAP bytes", run); + } + + ///////////////////////////////////////////////////////////////// + // Certificate credentials retrievability by withCredentials() step + ///////////////////////////////////////////////////////////////// + + String cpsScriptCertCredentialTestGetKeyValue() { + return "@NonCPS\n" + + "def getKeyValue(def keystoreName, def keystoreFormat, def keyPassword, def alias) {\n" + + " def p12file = new FileInputStream(keystoreName)\n" + + " def keystore = KeyStore.getInstance(keystoreFormat)\n" + + " keystore.load(p12file, keyPassword.toCharArray())\n" + + " p12file.close()\n" + + " def key = keystore.getKey(alias, keyPassword.toCharArray())\n" + + " return key.getEncoded().encodeBase64().toString()\n" + + "}\n" + + "\n"; + } + + String cpsScriptCertCredentialTestWithCredentials(String runnerTag) { + return cpsScriptCertCredentialTestWithCredentials("myCert", "password", "1", runnerTag); + } + + String cpsScriptCertCredentialTestWithCredentials(String id, String password, String alias, String runnerTag) { + // Note: does not pass a(ny) useful (env?.)myKeyAlias to closure + // https://issues.jenkins.io/browse/JENKINS-59331 + // https://github.com/jenkinsci/credentials-binding-plugin/blob/fcd22059ac48b87d0924ef17d5b351a3b7a89a97/src/main/java/org/jenkinsci/plugins/credentialsbinding/impl/CertificateMultiBinding.java#L80-L81 + return "def authentication='" + id + "';\n" + + "def password='" + password + "';\n" + + "def alias='" + alias + "';\n" + + "echo \"WITH-CREDENTIALS ON " + runnerTag + ":\"\n" + + "withCredentials([certificate(\n" + + " credentialsId: authentication,\n" + + " keystoreVariable: 'keystoreName',\n" + + " passwordVariable: 'keyPassword',\n" + + " aliasVariable: 'myKeyAlias')\n" + + "]) {\n" + + " echo \"Keystore bytes (len): \" + (new File(keystoreName)).length()\n" + + " echo \"Got expected key pass? ${keyPassword == password}\"\n" + + " def keystoreFormat = \"PKCS12\"\n" + + " def keyValue = getKeyValue(keystoreName, keystoreFormat, keyPassword, (env?.myKeyAlias ? env?.myKeyAlias : alias))\n" + + " println \"-----BEGIN PRIVATE KEY-----\"\n" + + " println keyValue\n" + + " println \"-----END PRIVATE KEY-----\"\n" + + "}\n" + + "\n"; + } + + @Test + @Ignore("Work with keystore file requires a node") + @Issue("JENKINS-70101") + public void testCertWithCredentialsOnController() throws Exception { + // Check that credentials are usable with pipeline script + // running without a node{} + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + cpsScriptCertCredentialTestGetKeyValue() + + cpsScriptCertCredentialTestWithCredentials("CONTROLLER BUILT-IN"); + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("END PRIVATE KEY", run); + } + + @Test + @Issue("JENKINS-70101") + public void testCertWithCredentialsOnNodeLocal() throws Exception { + // Check that credentials are usable with pipeline script + // running on a node{} (provided by the controller) + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + cpsScriptCertCredentialTestGetKeyValue() + + "node {\n" + + cpsScriptCertCredentialTestWithCredentials("CONTROLLER NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("END PRIVATE KEY", run); + } + + @Test + @Issue("JENKINS-70101") + public void testCertWithCredentialsOnNodeRemote() throws Exception { + // Check that credentials are usable with pipeline script + // running on a remote node{} with separate JVM (check + // that remoting/snapshot work properly) + assumeThat("This test needs a separate build agent", this.setupAgent(), is(true)); + + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + cpsScriptCertCredentialTestGetKeyValue() + + "node(\"worker\") {\n" + + cpsScriptCertCredentialTestWithCredentials("REMOTE NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("END PRIVATE KEY", run); + } + + ///////////////////////////////////////////////////////////////// + // Certificate credentials retrievability by http-request-plugin + ///////////////////////////////////////////////////////////////// + + String cpsScriptCertCredentialTestHttpRequest(String runnerTag) { + return cpsScriptCredentialTestHttpRequest("myCert", runnerTag, true); + } + + String cpsScriptCredentialTestHttpRequest(String id, String runnerTag, Boolean withLocalCertLookup) { + // Note: we accept any outcome (for the plugin, unresolved host is HTTP-404) + // but it may not crash making use of the credential + // Note: cases withLocalCertLookup also need cpsScriptCredentialTestImports() + return "def authentication='" + id + "';\n" + + "\n" + + "def msg\n" + + (withLocalCertLookup ? ( + "if (true) { // scoping\n" + + " msg = \"Finding credential...\"\n" + + " echo msg;" + (verbosePipelines ? " System.out.println(msg); System.err.println(msg)" : "" ) + ";\n" + + " StandardCredentials credential = CredentialsMatchers.firstOrNull(\n" + + " CredentialsProvider.lookupCredentials(\n" + + " StandardCredentials.class,\n" + + " Jenkins.instance, null, null),\n" + + " CredentialsMatchers.withId(authentication));\n" + + " msg = \"Getting keystore...\"\n" + + " echo msg;" + (verbosePipelines ? " System.out.println(msg); System.err.println(msg)" : "" ) + ";\n" + + " KeyStore keyStore = credential.getKeyStore();\n" + + " msg = \"Getting keystore source...\"\n" + + " echo msg;" + (verbosePipelines ? " System.out.println(msg); System.err.println(msg)" : "" ) + ";\n" + + " KeyStoreSource kss = ((CertificateCredentialsImpl) credential).getKeyStoreSource();\n" + + " msg = \"Getting keystore source bytes...\"\n" + + " echo msg;" + (verbosePipelines ? " System.out.println(msg); System.err.println(msg)" : "" ) + ";\n" + + " byte[] kssb = kss.getKeyStoreBytes();\n" + + "}\n" ) + : "" ) + + "\n" + + "msg = \"Querying HTTPS with cert...\"\n" + + "echo msg;" + (verbosePipelines ? " System.out.println(msg); System.err.println(msg)" : "" ) + ";\n" + + "def response = httpRequest(url: 'https://github.xcom/api/v3',\n" + + " httpMode: 'GET',\n" + + " authentication: authentication,\n" + + " consoleLogResponseBody: true,\n" + + " contentType : 'APPLICATION_FORM',\n" + + " validResponseCodes: '100:599',\n" + + " quiet: false)\n" + + "println('HTTP Request Plugin Status: '+ response.getStatus())\n" + + "println('HTTP Request Plugin Response: '+ response.getContent())\n" + + "\n"; + } + + @Test + @Issue("JENKINS-70101") + public void testCertHttpRequestOnController() throws Exception { + // Check that credentials are usable with pipeline script + // running without a node{} + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + cpsScriptCertCredentialTestHttpRequest("CONTROLLER BUILT-IN"); + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + + @Test + @Issue("JENKINS-70101") + public void testCertHttpRequestOnNodeLocal() throws Exception { + // Check that credentials are usable with pipeline script + // running on a node{} (provided by the controller) + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + "node {\n" + + cpsScriptCertCredentialTestHttpRequest("CONTROLLER NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + + @Test + @Issue("JENKINS-70101") + public void testCertHttpRequestOnNodeRemote() throws Exception { + // Check that credentials are usable with pipeline script + // running on a remote node{} with separate JVM (check + // that remoting/snapshot work properly) + assumeThat("This test needs a separate build agent", this.setupAgent(), is(true)); + + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + "node(\"worker\") {\n" + + cpsScriptCertCredentialTestHttpRequest("REMOTE NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + + ///////////////////////////////////////////////////////////////// + // User/pass credentials tests + ///////////////////////////////////////////////////////////////// + + // Partially from UsernamePasswordCredentialsImplTest setup() + private void prepareUsernamePassword() throws IOException { + UsernamePasswordCredentialsImpl credentials = + new UsernamePasswordCredentialsImpl(null, + "abc123", "Bob’s laptop", + "bob", "s3cr3t"); + SystemCredentialsProvider.getInstance().getCredentials().add(credentials); + SystemCredentialsProvider.getInstance().save(); + } + + String cpsScriptUsernamePasswordCredentialTestHttpRequest(String runnerTag) { + return cpsScriptCredentialTestHttpRequest("abc123", runnerTag, false); + } + + @Test + @Issue("JENKINS-70101") + public void testUsernamePasswordHttpRequestOnController() throws Exception { + // Check that credentials are usable with pipeline script + // running without a node{} + prepareUsernamePassword(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptUsernamePasswordCredentialTestHttpRequest("CONTROLLER BUILT-IN"); + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + + @Test + @Issue("JENKINS-70101") + public void testUsernamePasswordHttpRequestOnNodeLocal() throws Exception { + // Check that credentials are usable with pipeline script + // running on a node{} (provided by the controller) + prepareUsernamePassword(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = + "node {\n" + + cpsScriptUsernamePasswordCredentialTestHttpRequest("CONTROLLER NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + + @Test + @Issue("JENKINS-70101") + public void testUsernamePasswordHttpRequestOnNodeRemote() throws Exception { + // Check that credentials are usable with pipeline script + // running on a remote node{} with separate JVM (check + // that remoting/snapshot work properly) + assumeThat("This test needs a separate build agent", this.setupAgent(), is(true)); + + prepareUsernamePassword(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = + "node(\"worker\") {\n" + + cpsScriptUsernamePasswordCredentialTestHttpRequest("REMOTE NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + +}