Moderate severityNVD Advisory· Published May 6, 2020· Updated Aug 4, 2024
CVE-2020-2187
CVE-2020-2187
Description
Jenkins Amazon EC2 Plugin 1.50.1 and earlier unconditionally accepts self-signed certificates and does not perform hostname validation, enabling man-in-the-middle attacks.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.jenkins-ci.plugins:ec2Maven | < 1.50.2 | 1.50.2 |
Affected products
1- Range: unspecified
Patches
14c9f03ae202eSECURITY-1408, SECURITY-381, SECURITY-1528
42 files changed · +2194 −50
.gitignore+2 −0 modified@@ -11,3 +11,5 @@ target /bin /work /src_generated +.history +.factorypath \ No newline at end of file
pom.xml+7 −0 modified@@ -246,6 +246,12 @@ THE SOFTWARE. <version>${jcasc.version}</version> <scope>test</scope> </dependency> + <dependency> + <groupId>org.testcontainers</groupId> + <artifactId>testcontainers</artifactId> + <version>1.8.3</version> + <scope>test</scope> + </dependency> </dependencies> <dependencyManagement> @@ -420,6 +426,7 @@ THE SOFTWARE. <configuration> <compatibleSinceVersion>1.45</compatibleSinceVersion> <pluginFirstClassLoader>true</pluginFirstClassLoader> + <minimumJavaVersion>8</minimumJavaVersion> </configuration> </plugin> <plugin>
README.md+150 −0 modified@@ -4,6 +4,31 @@ [](https://github.com/jenkinsci/ec2-plugin/releases/latest) [](https://gitter.im/ec2-plugin/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +# Table of contents + * [Introduction](#introduction) + * [Usage](#usage) + * [Spot Instances](#spot-instances) + * [Enable Spot Request](#enable-spot-request) + * [Configure Jenkins for Spot Support](#configure-jenkins-for-spot-support) + * [Configure AMI for Spot Support](#configure-ami-for-spot-support) + * [IAM setup](#iam-setup) + * [Configure plugin via Groovy script](#configure-plugin-via-groovy-script) + * [Security](#security) + * [Securing the connection to Unix AMIs](#securing-the-connection-to-unix-amis) + * [Strategies](#strategies) + * [Check New Hard](#check-new-hard) + * [Check New Soft](#check-new-soft) + * [Accept New](#accept-new) + * [Off](#off) + * [New AMIs](#new-amis) + * [Upgrade - Existing AMIs](#upgrade---existing-amis) + * [Securing the connection to Windows AMIs](#securing-the-connection-to-windows-amis) + * [AMI Set Up](#ami-set-up) + * [Known Issues](#known-issues) + * [Authentication Timeout](#authentication-timeout) + * [Amazon Linux build/connectivity issues](#amazon-linux-buildconnectivity-issues) + * [Change Log](#change-log) + # Introduction Allow Jenkins to start agents on @@ -419,6 +444,131 @@ jenkins.clouds.add(amazonEC2Cloud) jenkins.save() ``` +# Security +## Securing the connection to Unix AMIs +When you set up a template for a *Unix* instance (`Type AMI` field), you can select the strategy used to guarantee the +instance you're connecting to is the expected one. You should use a strong strategy to guarantee that a +_[man-in-the-middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack)_ cannot be performed. + +You can select your strategy under the _Advanced..._ configuration, on the _Host Key Verification Strategy_ field of +every configured AMI. + +The plugin provides several strategies because each one has its own requirements. So providing more than one allows + administrators to use the one best fits to their environment. These strategies are: + +### Strategies +#### Check New Hard +This strategy checks the SSH host key provided by the instance with the key printed out in the instance console during +the instance initialization. If the key is not found, the plugin **doesn't allow** the connection to the instance to +guarantee the instance is the right one. If the key is found and it is the same as the one presented by the instance, +then it's saved to be used on future connections, so the console is only checked once. + +Requirements: + +* The AMI used should print the key used. It's a common behaviour, for example the _Amazon Linux 2_ AMI prints it +out. You can consult the AMI documentation to figure it out. +* The launch timeout should be long enough to allow the plugin to check the instance console. With this strategy, the +plugin waits for the console to be available, which can take a few minutes. The _Launch Timeout in seconds_ field should +have a number to allow that, for example 600 (10 minutes). By default there is no timeout, so it's safe. + +The expected format on the instance console is `algorithm base64-public-key` at the beginning of a line. For example: +``` +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNFNGfKpPS/UT2jAEa0+9aZneku2a7TVwN+MjGesm65DDGnXPcM9TM9BsiOE+s4Vo6aCT9L/TVrtDFa0hqbnqc8= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHm0sVqkjSuaPg8e7zfaKXt3b1hE1tBwFsB18NOWv5ow +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNTngsAxOCpZwt+IBqJSQ9MU2qVNYzP4D5i1OHfIRXCrnAuJ54GtFzZEZqqo4e1e/JqBQOX3ZPsaegbkzl2uq5FzfFcFoYYXg5gL7htlZ1I2k6/2iIBv7CHAjbpXMkH8WoF2C3vZFRMWLs20ikQpED+9m11VejE19+kqJwLMopyAtq+/mCgiv4nw5QWh3rrrEcbgzuxYoMD0t9daqBq1V0lzRqL36ALVySy7oDjr3YzCN+wMXe1I36kv3lSeCHXnhc53ubrBIsRakWLBndHhPqyyAOMEjdby/O/EQ2PR7vBpH5MaseaJwvRRDPQ6qt4sV8lk0tEt9qbdb1prFRB4W1 +``` +Recommended for: + +This strategy is the most secure. It's recommended for every instance if you can meet the requirements. We recommend, +whenever possible, configuring each AMI with _Stop/Disconnect on Idle Timeout_ to take advantage of the ssh host key +cache allowing next connections to be done faster. + +#### Check New Soft +This strategy checks the SSH host key provided by the instance with the key printed out in the instance console during +the instance initialization. If the key is not found, the plugin **allows** the connection to the instance in order to +guarantee the instance is the right one. If the key is found and it is the same as the one presented by the instance, +then it's saved to be used on future connections, so the console is only checked once. + +Requirements: + +* The AMI used may print the key used to guarantee the instance is the right one, but **it's not mandatory**. +* The launch timeout should be long enough to allow the plugin to check the instance console. With this strategy, the +plugin waits for the console to be available, which can take a few minutes. The _Launch Timeout in seconds_ field should +have a number to allow that. For example 600 (10 minutes). By default there is no timeout, so it's safe. If the timeout +expires, the connection is not done. + +The expected format on the instance console is `algorithm base64-public-key` at the beginning of a line. For example: +``` +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNFNGfKpPS/UT2jAEa0+9aZneku2a7TVwN+MjGesm65DDGnXPcM9TM9BsiOE+s4Vo6aCT9L/TVrtDFa0hqbnqc8= +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHm0sVqkjSuaPg8e7zfaKXt3b1hE1tBwFsB18NOWv5ow +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNTngsAxOCpZwt+IBqJSQ9MU2qVNYzP4D5i1OHfIRXCrnAuJ54GtFzZEZqqo4e1e/JqBQOX3ZPsaegbkzl2uq5FzfFcFoYYXg5gL7htlZ1I2k6/2iIBv7CHAjbpXMkH8WoF2C3vZFRMWLs20ikQpED+9m11VejE19+kqJwLMopyAtq+/mCgiv4nw5QWh3rrrEcbgzuxYoMD0t9daqBq1V0lzRqL36ALVySy7oDjr3YzCN+wMXe1I36kv3lSeCHXnhc53ubrBIsRakWLBndHhPqyyAOMEjdby/O/EQ2PR7vBpH5MaseaJwvRRDPQ6qt4sV8lk0tEt9qbdb1prFRB4W1 +``` +Recommended for: + +This strategy is the default one for AMIs created with a former version of the plugin. It doesn't break any connection +because the plugin connects to the instance even when the key is not found on the console. The only point to take into + account is you need to have the right timeout to allow the plugin to get the instance console. This strategy is recommended +when upgrading from a previous version of the plugin. _Check New Hard_ is the safest strategy, so you should +consider migrating to it. We recommend, whenever possible, configuring each AMI with _Stop/Disconnect on Idle Timeout_ + to take advantage of the ssh host key cache allowing next connections to be done faster. + +#### Accept New +This strategy doesn't check any key on the console. It accepts the key provided by the instance on the first +connection. Then, the key is saved to be used on future connections to detect a Man-in-the-Middle attack (the host +key has changed). + +Requirements: +* N/A + +Recommended for: + +This strategy is recommended when your AMIs don't print out the host keys on the console. The _Check New Soft_ cannot be + used, but at least, you can catch a man-in-the-middle attack on further connections to the same instance. If the attack + was already perpetrated you cannot detect that. Again, the _Check New Hard_ is the safest strategy. + +#### Off +This strategy neither checks any key on the console, nor checks future connections to the same instance with a saved +key. It accepts blindly the key provided by the instance on the first and further connections. + +Requirements: +* N/A + +Recommended for: + +This strategy is not recommended because of its lack of security. It is the strategy used for prior versions of the plugin. + +### New AMIs +The default strategy for every new instance is the _Check New Hard_ one. You can select a strategy per AMI. It's under +the _Advanced..._ configuration, on the _Host Key Verification Strategy_ field. + +### Upgrade - Existing AMIs +You may upgrade from a Jenkins installation with a former plugin version without this security mechanism. The default + strategy for every existing instance is the _Check New Soft_ strategy. This guarantees your jobs are not going to stop + working and improves the situation. We recommend, if possible, upgrading to the _Check New Hard_ strategy to be safer + against a _Man in the Middle attack_. + +## Securing the connection to Windows AMIs +When you configure a template for a *Windows* instance (`Type AMI` field), you can use HTTPS and disallow +self-signed certificates. This guarantees the instance you're connecting to is the expected one and a +[man-in-the-middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) cannot be performed. + +### AMI Set Up +Before securely connecting to the instance, you need to 1) configure the AMI, 2)install the +certificate, 3) configure WinRM properly and 4) set the firewall rules to allow the connection. You can find some +guidance at the `AMI Type` field help, under the template configuration on your Jenkins instance. + +Tips: +* When the `Allow Self Signed Certificate` field is checked, the plugin checks the CA which issued the +certificate and verifies the host it is connecting to is present on the certificate. If the field is not checked, both checks are skipped. +* The EC2 plugin connects to the instance using either an IP address. It does not use the DNS name. You must configure WinRM with a certificate which includes +the **IP** of the instance. Something like: +``` +#3: ObjectId: 2.5.29.17 Criticality=false +SubjectAlternativeName [ + DNSName: myhostname.com + IPAddress: 111.222.333.444 <-------------- +] +``` # Known Issues ## Authentication Timeout
src/main/java/hudson/plugins/ec2/AmazonEC2Cloud.java+2 −0 modified@@ -162,12 +162,14 @@ public FormValidation doCheckCloudName(@QueryParameter String value) { return FormValidation.ok(); } + @RequirePOST public ListBoxModel doFillRegionItems( @QueryParameter String altEC2Endpoint, @QueryParameter boolean useInstanceProfileForCredentials, @QueryParameter String credentialsId) throws IOException, ServletException { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); ListBoxModel model = new ListBoxModel();
src/main/java/hudson/plugins/ec2/EC2AbstractSlave.java+4 −0 modified@@ -735,6 +735,10 @@ public int getBootDelay() { public boolean isSpecifyPassword() { return amiType.isWindows() && ((WindowsData) amiType).isSpecifyPassword(); } + + public boolean isAllowSelfSignedCertificate() { + return amiType.isWindows() && ((WindowsData) amiType).isAllowSelfSignedCertificate(); + } public static ListBoxModel fillZoneItems(AWSCredentialsProvider credentialsProvider, String region) { ListBoxModel model = new ListBoxModel();
src/main/java/hudson/plugins/ec2/EC2Cloud.java+20 −0 modified@@ -116,6 +116,7 @@ import hudson.util.HttpResponses; import hudson.util.Secret; import hudson.util.StreamTaskListener; +import org.kohsuke.stapler.interceptor.RequirePOST; /** * Hudson's view of EC2. @@ -325,6 +326,7 @@ public synchronized KeyPair getKeyPair() throws AmazonClientException, IOExcepti /** * Debug command to attach to a running instance. */ + @RequirePOST public void doAttach(StaplerRequest req, StaplerResponse rsp, @QueryParameter String id) throws ServletException, IOException, AmazonClientException { checkPermission(PROVISION); @@ -338,6 +340,7 @@ public void doAttach(StaplerRequest req, StaplerResponse rsp, @QueryParameter St rsp.sendRedirect2(req.getContextPath() + "/computer/" + node.getNodeName()); } + @RequirePOST public HttpResponse doProvision(@QueryParameter String template) throws ServletException, IOException { checkPermission(PROVISION); if (template == null) { @@ -959,6 +962,21 @@ public FormValidation doCheckPrivateKey(@QueryParameter String value) throws IOE return FormValidation.ok(); } + /** + * Tests the connection settings. + * + * Overriding needs to {@code @RequirePOST} + * @param ec2endpoint + * @param useInstanceProfileForCredentials + * @param credentialsId + * @param privateKey + * @param roleArn + * @param roleSessionName + * @param region + * @return the validation result + * @throws IOException + * @throws ServletException + */ protected FormValidation doTestConnection(URL ec2endpoint, boolean useInstanceProfileForCredentials, String credentialsId, String privateKey, String roleArn, String roleSessionName, String region) throws IOException, ServletException { Jenkins.get().checkPermission(Jenkins.ADMINISTER); @@ -986,7 +1004,9 @@ protected FormValidation doTestConnection(URL ec2endpoint, boolean useInstancePr } } + @RequirePOST public ListBoxModel doFillCredentialsIdItems() { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); return new StandardListBoxModel() .withEmptySelection() .withMatching(
src/main/java/hudson/plugins/ec2/EC2Computer.java+10 −0 modified@@ -97,6 +97,16 @@ public String getConsoleOutput() throws AmazonClientException { return ec2.getConsoleOutput(request).getOutput(); } + /** + * Gets the EC2 decoded console output. + * @since TODO + */ + public String getDecodedConsoleOutput() throws AmazonClientException { + AmazonEC2 ec2 = getCloud().connect(); + GetConsoleOutputRequest request = new GetConsoleOutputRequest(getInstanceId()); + return ec2.getConsoleOutput(request).getDecodedOutput(); + } + /** * Obtains the instance state description in EC2. *
src/main/java/hudson/plugins/ec2/EC2RetentionStrategy.java+1 −1 modified@@ -174,7 +174,7 @@ private long internalCheck(EC2Computer computer) { return 1; } long launchTimeout = node.getLaunchTimeoutInMillis(); - if(uptime > launchTimeout){ + if (launchTimeout > 0 && uptime > launchTimeout){ // Computer is offline and startup time has expired LOGGER.info("Startup timeout of " + computer.getName() + " after " + uptime +
src/main/java/hudson/plugins/ec2/HostKeyVerificationStrategyEnum.java+67 −0 added@@ -0,0 +1,67 @@ +/* + * The MIT License + * + * Copyright (c) 2020-, M Ramon Leon, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package hudson.plugins.ec2; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.plugins.ec2.ssh.verifiers.AcceptNewStrategy; +import hudson.plugins.ec2.ssh.verifiers.CheckNewHardStrategy; +import hudson.plugins.ec2.ssh.verifiers.CheckNewSoftStrategy; +import hudson.plugins.ec2.ssh.verifiers.NonVerifyingKeyVerificationStrategy; +import hudson.plugins.ec2.ssh.verifiers.SshHostKeyVerificationStrategy; + +public enum HostKeyVerificationStrategyEnum { + CHECK_NEW_HARD("check-new-hard", "yes", new CheckNewHardStrategy()), + CHECK_NEW_SOFT("check-new-soft", "accept-new", new CheckNewSoftStrategy()), + ACCEPT_NEW("accept-new", "accept-new", new AcceptNewStrategy()), + OFF("off", "off", new NonVerifyingKeyVerificationStrategy()); + + private final String displayText; + private final SshHostKeyVerificationStrategy strategy; + private final String sshCommandEquivalentFlag; + + HostKeyVerificationStrategyEnum(@NonNull String displayText, @NonNull String sshCommandEquivalentFlag, @NonNull SshHostKeyVerificationStrategy strategy) { + this.displayText = displayText; + this.sshCommandEquivalentFlag = sshCommandEquivalentFlag; + this.strategy = strategy; + } + + @NonNull + public SshHostKeyVerificationStrategy getStrategy() { + return strategy; + } + + public boolean equalsDisplayText(String other) { + return this.displayText.equals(other); + } + + @NonNull + public String getDisplayText() { + return displayText; + } + + @NonNull + public String getSshCommandEquivalentFlag() { + return sshCommandEquivalentFlag; + } +}
src/main/java/hudson/plugins/ec2/PluginImpl.java+24 −2 modified@@ -23,18 +23,40 @@ */ package hudson.plugins.ec2; -import hudson.plugins.ec2.util.MinimumInstanceChecker; -import jenkins.model.Jenkins; import hudson.Extension; import hudson.Plugin; import hudson.model.Describable; import hudson.model.Descriptor; +import hudson.plugins.ec2.util.MinimumInstanceChecker; +import jenkins.model.Jenkins; + +import java.io.IOException; +import java.util.logging.Logger; /** * Added to handle backwards compatibility of xstream class name mapping. */ @Extension public class PluginImpl extends Plugin implements Describable<PluginImpl> { + private static final Logger LOGGER = Logger.getLogger(PluginImpl.class.getName()); + + // Whether the SshHostKeyVerificationAdministrativeMonitor should show messages when we have templates using + // accept-new or check-new-soft strategies + private long dismissInsecureMessages; + + public void saveDismissInsecureMessages(long dismissInsecureMessages) { + this.dismissInsecureMessages = dismissInsecureMessages; + try { + save(); + } catch(IOException io) { + LOGGER.warning("There was a problem saving that you want to dismiss all messages related to insecure EC2 templates"); + } + } + + public long getDismissInsecureMessages() { + return dismissInsecureMessages; + } + @Override public void start() throws Exception { // backward compatibility with the legacy class name
src/main/java/hudson/plugins/ec2/SlaveTemplate.java+90 −9 modified@@ -33,6 +33,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; @@ -41,10 +42,12 @@ import javax.servlet.ServletException; +import edu.umd.cs.findbugs.annotations.NonNull; import hudson.plugins.ec2.util.*; import hudson.XmlFile; import hudson.model.listeners.SaveableListener; +import hudson.security.Permission; import hudson.util.Secret; import jenkins.model.Jenkins; import jenkins.model.JenkinsLocationConfiguration; @@ -73,6 +76,8 @@ import hudson.util.DescribableList; import hudson.util.FormValidation; import hudson.util.ListBoxModel; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.interceptor.RequirePOST; /** * Template of {@link EC2AbstractSlave} to launch. @@ -143,6 +148,8 @@ public class SlaveTemplate implements Describable<SlaveTemplate> { private final List<EC2Tag> tags; public ConnectionStrategy connectionStrategy; + + public HostKeyVerificationStrategyEnum hostKeyVerificationStrategy; public final boolean associatePublicIp; @@ -191,14 +198,14 @@ public class SlaveTemplate implements Describable<SlaveTemplate> { @DataBoundConstructor public SlaveTemplate(String ami, String zone, SpotConfiguration spotConfig, String securityGroups, String remoteFS, - InstanceType type, boolean ebsOptimized, String labelString, Node.Mode mode, String description, String initScript, - String tmpDir, String userData, String numExecutors, String remoteAdmin, AMITypeData amiType, String jvmopts, - boolean stopOnTerminate, String subnetId, List<EC2Tag> tags, String idleTerminationMinutes, int minimumNumberOfInstances, - int minimumNumberOfSpareInstances, String instanceCapStr, String iamInstanceProfile, boolean deleteRootOnTermination, - boolean useEphemeralDevices, boolean useDedicatedTenancy, String launchTimeoutStr, boolean associatePublicIp, - String customDeviceMapping, boolean connectBySSHProcess, boolean monitoring, - boolean t2Unlimited, ConnectionStrategy connectionStrategy, int maxTotalUses, - List<? extends NodeProperty<?>> nodeProperties) { + InstanceType type, boolean ebsOptimized, String labelString, Node.Mode mode, String description, String initScript, + String tmpDir, String userData, String numExecutors, String remoteAdmin, AMITypeData amiType, String jvmopts, + boolean stopOnTerminate, String subnetId, List<EC2Tag> tags, String idleTerminationMinutes, int minimumNumberOfInstances, + int minimumNumberOfSpareInstances, String instanceCapStr, String iamInstanceProfile, boolean deleteRootOnTermination, + boolean useEphemeralDevices, boolean useDedicatedTenancy, String launchTimeoutStr, boolean associatePublicIp, + String customDeviceMapping, boolean connectBySSHProcess, boolean monitoring, + boolean t2Unlimited, ConnectionStrategy connectionStrategy, int maxTotalUses, + List<? extends NodeProperty<?>> nodeProperties, HostKeyVerificationStrategyEnum hostKeyVerificationStrategy) { if(StringUtils.isNotBlank(remoteAdmin) || StringUtils.isNotBlank(jvmopts) || StringUtils.isNotBlank(tmpDir)){ LOGGER.log(Level.FINE, "As remoteAdmin, jvmopts or tmpDir is not blank, we must ensure the user has ADMINISTER rights."); // Can be null during tests @@ -261,9 +268,32 @@ public SlaveTemplate(String ami, String zone, SpotConfiguration spotConfig, Stri this.customDeviceMapping = customDeviceMapping; this.t2Unlimited = t2Unlimited; + this.hostKeyVerificationStrategy = hostKeyVerificationStrategy != null ? hostKeyVerificationStrategy : HostKeyVerificationStrategyEnum.CHECK_NEW_SOFT; + readResolve(); // initialize } + @Deprecated + public SlaveTemplate(String ami, String zone, SpotConfiguration spotConfig, String securityGroups, String remoteFS, + InstanceType type, boolean ebsOptimized, String labelString, Node.Mode mode, String description, String initScript, + String tmpDir, String userData, String numExecutors, String remoteAdmin, AMITypeData amiType, String jvmopts, + boolean stopOnTerminate, String subnetId, List<EC2Tag> tags, String idleTerminationMinutes, int minimumNumberOfInstances, + int minimumNumberOfSpareInstances, String instanceCapStr, String iamInstanceProfile, boolean deleteRootOnTermination, + boolean useEphemeralDevices, boolean useDedicatedTenancy, String launchTimeoutStr, boolean associatePublicIp, + String customDeviceMapping, boolean connectBySSHProcess, boolean monitoring, + boolean t2Unlimited, ConnectionStrategy connectionStrategy, int maxTotalUses, + List<? extends NodeProperty<?>> nodeProperties) { + this(ami, zone, spotConfig, securityGroups, remoteFS, + type, ebsOptimized, labelString, mode, description, initScript, + tmpDir, userData, numExecutors, remoteAdmin, amiType, jvmopts, + stopOnTerminate, subnetId, tags, idleTerminationMinutes, minimumNumberOfInstances, + minimumNumberOfSpareInstances, instanceCapStr, iamInstanceProfile, deleteRootOnTermination, + useEphemeralDevices, useDedicatedTenancy, launchTimeoutStr, associatePublicIp, + customDeviceMapping, connectBySSHProcess, monitoring, + t2Unlimited, connectionStrategy, maxTotalUses, + nodeProperties, null); + } + @Deprecated public SlaveTemplate(String ami, String zone, SpotConfiguration spotConfig, String securityGroups, String remoteFS, InstanceType type, boolean ebsOptimized, String labelString, Node.Mode mode, String description, String initScript, @@ -604,6 +634,16 @@ public String getIamInstanceProfile() { return iamInstanceProfile; } + @DataBoundSetter + public void setHostKeyVerificationStrategy(HostKeyVerificationStrategyEnum hostKeyVerificationStrategy) { + this.hostKeyVerificationStrategy = (hostKeyVerificationStrategy != null) ? hostKeyVerificationStrategy : HostKeyVerificationStrategyEnum.CHECK_NEW_SOFT; + } + + @NonNull + public HostKeyVerificationStrategyEnum getHostKeyVerificationStrategy() { + return hostKeyVerificationStrategy != null ? hostKeyVerificationStrategy : HostKeyVerificationStrategyEnum.CHECK_NEW_SOFT; + } + @Override public String toString() { return "SlaveTemplate{" + @@ -1411,6 +1451,10 @@ public boolean isUseHTTPS() { return amiType.isWindows() && ((WindowsData) amiType).isUseHTTPS(); } + public boolean isAllowSelfSignedCertificate() { + return amiType.isWindows() && ((WindowsData) amiType).isAllowSelfSignedCertificate(); + } + @Extension public static final class OnSaveListener extends SaveableListener { @Override @@ -1484,10 +1528,12 @@ public FormValidation doCheckJvmopts(@QueryParameter String value){ /*** * Check that the AMI requested is available in the cloud and can be used. */ + @RequirePOST public FormValidation doValidateAmi(@QueryParameter boolean useInstanceProfileForCredentials, @QueryParameter String credentialsId, @QueryParameter String ec2endpoint, @QueryParameter String region, final @QueryParameter String ami, @QueryParameter String roleArn, @QueryParameter String roleSessionName) throws IOException { + checkPermission(EC2Cloud.PROVISION); AWSCredentialsProvider credentialsProvider = EC2Cloud.createCredentialsProvider(useInstanceProfileForCredentials, credentialsId, roleArn, roleSessionName, region); AmazonEC2 ec2; if (region != null) { @@ -1507,6 +1553,15 @@ public FormValidation doValidateAmi(@QueryParameter boolean useInstanceProfileFo } } + private void checkPermission(Permission p) { + final EC2Cloud ancestorObject = Stapler.getCurrentRequest().findAncestorObject(EC2Cloud.class); + if (ancestorObject != null) { + ancestorObject.checkPermission(p); + } else { + Jenkins.get().checkPermission(p); + } + } + public FormValidation doCheckLabelString(@QueryParameter String value, @QueryParameter Node.Mode mode) { if (mode == Node.Mode.EXCLUSIVE && (value == null || value.trim().isEmpty())) { return FormValidation.warning("You may want to assign labels to this node;" @@ -1656,10 +1711,12 @@ public FormValidation doCheckLaunchTimeoutStr(@QueryParameter String value) { return FormValidation.error("Launch Timeout must be a non-negative integer (or null)"); } + @RequirePOST public ListBoxModel doFillZoneItems(@QueryParameter boolean useInstanceProfileForCredentials, @QueryParameter String credentialsId, @QueryParameter String region, @QueryParameter String roleArn, @QueryParameter String roleSessionName) throws IOException, ServletException { + checkPermission(EC2Cloud.PROVISION); AWSCredentialsProvider credentialsProvider = EC2Cloud.createCredentialsProvider(useInstanceProfileForCredentials, credentialsId, roleArn, roleSessionName, region); return EC2AbstractSlave.fillZoneItems(credentialsProvider, region); } @@ -1701,6 +1758,30 @@ public FormValidation doCheckConnectionStrategy(@QueryParameter String connectio .map(s -> FormValidation.ok()) .orElse(FormValidation.error("Could not find selected connection strategy")); } - } + + public String getDefaultHostKeyVerificationStrategy() { + // new templates default to the most secure strategy + return HostKeyVerificationStrategyEnum.CHECK_NEW_HARD.name(); + } + + public ListBoxModel doFillHostKeyVerificationStrategyItems(@QueryParameter String hostKeyVerificationStrategy) { + return Stream.of(HostKeyVerificationStrategyEnum.values()) + .map(v -> { + if (v.name().equals(hostKeyVerificationStrategy)) { + return new ListBoxModel.Option(v.getDisplayText(), v.name(), true); + } else { + return new ListBoxModel.Option(v.getDisplayText(), v.name(), false); + } + }) + .collect(Collectors.toCollection(ListBoxModel::new)); + } + public FormValidation doCheckHostKeyVerificationStrategy(@QueryParameter String hostKeyVerificationStrategy) { + Stream<HostKeyVerificationStrategyEnum> stream = Stream.of(HostKeyVerificationStrategyEnum.values()); + Stream<HostKeyVerificationStrategyEnum> filteredStream = stream.filter(v -> v.name().equals(hostKeyVerificationStrategy)); + Optional<HostKeyVerificationStrategyEnum> matched = filteredStream.findFirst(); + Optional<FormValidation> okResult = matched.map(s -> FormValidation.ok()); + return okResult.orElse(FormValidation.error(String.format("Could not find selected host key verification (%s)", hostKeyVerificationStrategy))); + } + } }
src/main/java/hudson/plugins/ec2/SpotConfiguration.java+6 −0 modified@@ -22,6 +22,9 @@ import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.interceptor.RequirePOST; + +import static hudson.Functions.checkPermission; public final class SpotConfiguration extends AbstractDescribableImpl<SpotConfiguration> { public final boolean useBidPrice; @@ -129,11 +132,14 @@ public String getDisplayName() { /* * Check the current Spot price of the selected instance type for the selected region */ + @RequirePOST public FormValidation doCurrentSpotPrice(@QueryParameter boolean useInstanceProfileForCredentials, @QueryParameter String credentialsId, @QueryParameter String region, @QueryParameter String type, @QueryParameter String zone, @QueryParameter String roleArn, @QueryParameter String roleSessionName, @QueryParameter String ami) throws IOException, ServletException { + checkPermission(EC2Cloud.PROVISION); + String cp = ""; String zoneStr = "";
src/main/java/hudson/plugins/ec2/ssh/EC2UnixLauncher.java+37 −12 modified@@ -29,6 +29,9 @@ import hudson.model.Descriptor; import hudson.model.TaskListener; import hudson.plugins.ec2.*; +import hudson.plugins.ec2.ssh.verifiers.CheckNewHardStrategy; +import hudson.plugins.ec2.ssh.verifiers.HostKey; +import hudson.plugins.ec2.ssh.verifiers.Messages; import hudson.remoting.Channel; import hudson.remoting.Channel.Listener; import hudson.slaves.CommandLauncher; @@ -57,6 +60,7 @@ import com.trilead.ssh2.SCPClient; import com.trilead.ssh2.ServerHostKeyVerifier; import com.trilead.ssh2.Session; +import org.apache.commons.lang.StringUtils; /** * {@link ComputerLauncher} that connects to a Unix slave on EC2 by using SSH. @@ -245,7 +249,8 @@ protected void launchScript(EC2Computer computer, TaskListener listener) throws try { // Obviously the master must have an installed ssh client. - String sshClientLaunchString = String.format("ssh -o StrictHostKeyChecking=no -i %s %s@%s -p %d %s", identityKeyFile.getAbsolutePath(), node.remoteAdmin, getEC2HostAddress(computer, template), node.getSshPort(), launchString); + // Depending on the strategy selected on the UI, we set the StrictHostKeyChecking flag + String sshClientLaunchString = String.format("ssh -o StrictHostKeyChecking=%s -i %s %s@%s -p %d %s", slaveTemplate.getHostKeyVerificationStrategy().getSshCommandEquivalentFlag(), identityKeyFile.getAbsolutePath(), node.remoteAdmin, getEC2HostAddress(computer, template), node.getSshPort(), launchString); logInfo(computer, listener, "Launching remoting agent (via SSH client process): " + sshClientLaunchString); CommandLauncher commandLauncher = new CommandLauncher(sshClientLaunchString, null); @@ -396,26 +401,46 @@ private Connection connectToSsh(EC2Computer computer, TaskListener listener, Sla conn.setProxyData(proxyData); logInfo(computer, listener, "Using HTTP Proxy Configuration"); } - // currently OpenSolaris offers no way of verifying the host - // certificate, so just accept it blindly, - // hoping that no man-in-the-middle attack is going on. - conn.connect(new ServerHostKeyVerifier() { - public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm, byte[] serverHostKey) - throws Exception { - return true; - } - }, slaveConnectTimeout, slaveConnectTimeout); + + conn.connect(new ServerHostKeyVerifierImpl(computer, listener), slaveConnectTimeout, slaveConnectTimeout); logInfo(computer, listener, "Connected via SSH."); return conn; // successfully connected } catch (IOException e) { // keep retrying until SSH comes up logInfo(computer, listener, "Failed to connect via ssh: " + e.getMessage()); - logInfo(computer, listener, "Waiting for SSH to come up. Sleeping 5."); - Thread.sleep(5000); + + // If the computer was set offline because it's not trusted, we avoid persisting in connecting to it. + // The computer is offline for a long period + if (computer.isOffline() && StringUtils.isNotBlank(computer.getOfflineCauseReason()) && computer.getOfflineCauseReason().equals(Messages.OfflineCause_SSHKeyCheckFailed())) { + throw new AmazonClientException("The connection couldn't be established and the computer is now offline", e); + } else { + logInfo(computer, listener, "Waiting for SSH to come up. Sleeping 5."); + Thread.sleep(5000); + } } } } + /** + * Our host key verifier just pick up the right strategy and call its verify method. + */ + private static class ServerHostKeyVerifierImpl implements ServerHostKeyVerifier { + + private final EC2Computer computer; + private final TaskListener listener; + + public ServerHostKeyVerifierImpl(final EC2Computer computer, final TaskListener listener) { + this.computer = computer; + this.listener = listener; + } + + @Override + public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm, byte[] serverHostKey) throws Exception { + SlaveTemplate template = computer.getSlaveTemplate(); + return template != null && template.getHostKeyVerificationStrategy().getStrategy().verify(computer, new HostKey(serverHostKeyAlgorithm, serverHostKey), listener); + } + } + private static String getEC2HostAddress(EC2Computer computer, SlaveTemplate template) throws InterruptedException { Instance instance = computer.updateInstanceDescription(); ConnectionStrategy strategy = template.connectionStrategy;
src/main/java/hudson/plugins/ec2/ssh/verifiers/AcceptNewStrategy.java+62 −0 added@@ -0,0 +1,62 @@ +/* + * The MIT License + * + * Copyright (c) 2020-, M Ramon Leon, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package hudson.plugins.ec2.ssh.verifiers; + +import hudson.model.TaskListener; +import hudson.plugins.ec2.EC2Cloud; +import hudson.plugins.ec2.EC2Computer; +import hudson.slaves.OfflineCause; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * This strategy accepts any new host key and stores it in a known_hosts file inside the node directory called + * ssh-host-key.xml. Every next attempt to connect to the node, the key is checked against the one stored. It's the + * same approach as the accept-new value of the StrictHostKeyChecking flag of ssh_config. + * @author M Ramon Leon + * @since TODO + */ +public class AcceptNewStrategy extends SshHostKeyVerificationStrategy { + private static final Logger LOGGER = Logger.getLogger(AcceptNewStrategy.class.getName()); + + @Override + public boolean verify(EC2Computer computer, HostKey hostKey, TaskListener listener) throws IOException { + HostKey existingHostKey = HostKeyHelper.getInstance().getHostKey(computer); + if (null == existingHostKey) { + HostKeyHelper.getInstance().saveHostKey(computer, hostKey); + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("The SSH key %s %s has been automatically trusted for connections to %s", hostKey.getAlgorithm(), hostKey.getFingerprint(), computer.getName())); + return true; + } else if (existingHostKey.equals(hostKey)) { + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("Connection allowed after the host key has been verified")); + return true; + } else { + EC2Cloud.log(LOGGER, Level.WARNING, computer.getListener(), String.format("The SSH key (%s) presented by the instance has changed since first saved (%s). The connection to %s is closed to prevent a possible man-in-the-middle attack", hostKey.getFingerprint(), existingHostKey.getFingerprint(), computer.getName())); + // To avoid reconnecting continuously + computer.setTemporarilyOffline(true, OfflineCause.create(Messages._OfflineCause_SSHKeyCheckFailed())); + return false; + } + } +}
src/main/java/hudson/plugins/ec2/ssh/verifiers/CheckNewHardStrategy.java+81 −0 added@@ -0,0 +1,81 @@ +/* + * The MIT License + * + * Copyright (c) 2020-, M Ramon Leon, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package hudson.plugins.ec2.ssh.verifiers; + +import hudson.model.TaskListener; +import hudson.plugins.ec2.EC2Cloud; +import hudson.plugins.ec2.EC2Computer; +import hudson.slaves.OfflineCause; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * This strategy checks the key presented by the host with the one printed out in the instance console. The key should + * be printed with the format "algorithm key". Example: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJbvbEIoY3tqKwkeRW/L1FnbCLLp8a1TwSOyZHKJqFFR + * If the key is not found, the connection is not trusted. If it's found, the key is stored in the ssh-host-key.xml file + * of the node directory and checked on every further connection. + * @author M Ramon Leon + * @since TODO + */ +public class CheckNewHardStrategy extends SshHostKeyVerificationStrategy { + private static final Logger LOGGER = Logger.getLogger(CheckNewHardStrategy.class.getName()); + + @Override + public boolean verify(EC2Computer computer, HostKey hostKey, TaskListener listener) throws IOException { + HostKey existingHostKey = HostKeyHelper.getInstance().getHostKey(computer); + if (null == existingHostKey) { + HostKey consoleHostKey = getHostKeyFromConsole(LOGGER, computer, hostKey.getAlgorithm()); + + if (hostKey.equals(consoleHostKey)) { + HostKeyHelper.getInstance().saveHostKey(computer, hostKey); + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("The SSH key %s %s has been successfully checked against the instance console for connections to %s", hostKey.getAlgorithm(), hostKey.getFingerprint(), computer.getName())); + return true; + } else if (consoleHostKey == null) { + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("The instance console is blank. Cannot check the key. The connection to %s is not allowed", computer.getName())); + return false; // waiting for next retry to have the console filled up + } else if (consoleHostKey.getKey().length == 0) { + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("The SSH key (%s %s) presented by the instance has not been found on the instance console. Cannot check the key. The connection to %s is not allowed", hostKey.getAlgorithm(), hostKey.getFingerprint(), computer.getName())); + // it is the difference with the soft strategy, the key is not accepted + computer.setTemporarilyOffline(true, OfflineCause.create(Messages._OfflineCause_SSHKeyCheckFailed())); // avoid next try + return false; + } else { + EC2Cloud.log(LOGGER, Level.WARNING, computer.getListener(), String.format("The SSH key (%s %s) presented by the instance is different from the one printed out on the instance console (%s %s). The connection to %s is closed to prevent a possible man-in-the-middle attack", + hostKey.getAlgorithm(), hostKey.getFingerprint(), consoleHostKey.getAlgorithm(), consoleHostKey.getFingerprint(), computer.getName())); + // To avoid reconnecting continuously + computer.setTemporarilyOffline(true, OfflineCause.create(Messages._OfflineCause_SSHKeyCheckFailed())); + return false; + } + } else if (existingHostKey.equals(hostKey)) { + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("Connection allowed after the host key has been verified")); + return true; + } else { + EC2Cloud.log(LOGGER, Level.WARNING, computer.getListener(), String.format("The SSH key (%s) presented by the instance has changed since first saved (%s). The connection to %s is closed to prevent a possible man-in-the-middle attack", hostKey.getFingerprint(), existingHostKey.getFingerprint(), computer.getName())); + // To avoid reconnecting continuously + computer.setTemporarilyOffline(true, OfflineCause.create(Messages._OfflineCause_SSHKeyCheckFailed())); + return false; + } + } +}
src/main/java/hudson/plugins/ec2/ssh/verifiers/CheckNewSoftStrategy.java+84 −0 added@@ -0,0 +1,84 @@ +/* + * The MIT License + * + * Copyright (c) 2020-, M Ramon Leon, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package hudson.plugins.ec2.ssh.verifiers; + +import hudson.model.TaskListener; +import hudson.plugins.ec2.EC2Cloud; +import hudson.plugins.ec2.EC2Computer; +import hudson.slaves.OfflineCause; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * This strategy checks the key presented by the host with the one printed out in the instance console. The key should + * be printed with the format "algorithm key". Example: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJbvbEIoY3tqKwkeRW/L1FnbCLLp8a1TwSOyZHKJqFFR + * If the key is not found because the console is blank, the connection is closed and wait until console prints something. + * If the key is not found because the instance doesn't print any key, the connection is trusted. + * If it's found and the key presented by the instance doesn't match the one printed in the console, the connection is closed + * and a warning is logged. + * If the key is found, it's stored to check future connections. + * @author M Ramon Leon + * @since TODO + */ +public class CheckNewSoftStrategy extends SshHostKeyVerificationStrategy { + private static final Logger LOGGER = Logger.getLogger(CheckNewSoftStrategy.class.getName()); + + @Override + public boolean verify(EC2Computer computer, HostKey hostKey, TaskListener listener) throws IOException { + HostKey existingHostKey = HostKeyHelper.getInstance().getHostKey(computer); + if (null == existingHostKey) { + HostKey consoleHostKey = getHostKeyFromConsole(LOGGER, computer, hostKey.getAlgorithm()); + + if (hostKey.equals(consoleHostKey)) { + HostKeyHelper.getInstance().saveHostKey(computer, hostKey); + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("The SSH key %s %s has been successfully checked against the instance console for connections to %s", hostKey.getAlgorithm(), hostKey.getFingerprint(), computer.getName())); + return true; + } else if (consoleHostKey == null) { + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("The instance console is blank. Cannot check the key. The connection to %s is not allowed", computer.getName())); + return false; // waiting for next retry to have the console filled up + } else if (consoleHostKey.getKey().length == 0) { + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("The SSH key (%s %s) presented by the instance has not been found on the instance console. Cannot check the key but the connection to %s is allowed", hostKey.getAlgorithm(), hostKey.getFingerprint(), computer.getName())); + // it is the difference with the the hard strategy, the key is accepted + HostKeyHelper.getInstance().saveHostKey(computer, hostKey); + return true; + } else { + EC2Cloud.log(LOGGER, Level.WARNING, computer.getListener(), String.format("The SSH key (%s %s) presented by the instance is different from the one printed out on the instance console (%s %s). The connection to %s is closed to prevent a possible man-in-the-middle attack", + hostKey.getAlgorithm(), hostKey.getFingerprint(), consoleHostKey.getAlgorithm(), consoleHostKey.getFingerprint(), computer.getName())); + // To avoid reconnecting continuously + computer.setTemporarilyOffline(true, OfflineCause.create(Messages._OfflineCause_SSHKeyCheckFailed())); + return false; + } + } else if (existingHostKey.equals(hostKey)) { + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("Connection allowed after the host key has been verified")); + return true; + } else { + EC2Cloud.log(LOGGER, Level.WARNING, computer.getListener(), String.format("The SSH key (%s) presented by the instance has changed since first saved (%s). The connection to %s is closed to prevent a possible man-in-the-middle attack", hostKey.getFingerprint(), existingHostKey.getFingerprint(), computer.getName())); + // To avoid reconnecting continuously + computer.setTemporarilyOffline(true, OfflineCause.create(Messages._OfflineCause_SSHKeyCheckFailed())); + return false; + } + } +}
src/main/java/hudson/plugins/ec2/ssh/verifiers/HostKeyHelper.java+118 −0 added@@ -0,0 +1,118 @@ +/* + * The MIT License + * + * Original work from ssh-slaves-plugin Copyright (c) 2016, Michael Clarke + * Modified work Copyright (c) 2020-, M Ramon Leon, CloudBees, Inc. + * Modified work: + * - Just the since annotation + + * 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 hudson.plugins.ec2.ssh.verifiers; + +import hudson.XmlFile; +import hudson.model.Computer; +import hudson.model.Node; +import jenkins.model.Jenkins; + +import java.io.File; +import java.io.IOException; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Helper methods to allow loading and saving of host keys for a computer. Verifiers + * don't have a reference to the Node or Computer that they're running for at the point + * they're created, so can only load the existing key to run comparisons against at the + * point the verifier is invoked during the connection attempt. + * @author Michael Clarke, M Ramon Leon + * @since TODO + */ +public final class HostKeyHelper { + + private static final HostKeyHelper INSTANCE = new HostKeyHelper(); + + private final Map<Computer, HostKey> cache = new WeakHashMap<>(); + + private HostKeyHelper() { + super(); + } + + public static HostKeyHelper getInstance() { + return INSTANCE; + } + + + /** + * Retrieve the currently trusted host key for the requested computer, or null if + * no key is currently trusted. + * @param host the Computer to retrieve the key for. + * @return the currently trusted key for the requested host, or null if no key is trusted. + * @throws IOException if the host key can not be read from storage + */ + public HostKey getHostKey(Computer host) throws IOException { + HostKey key = cache.get(host); + if (null == key) { + File hostKeyFile = getSshHostKeyFile(host.getNode()); + if (hostKeyFile.exists()) { + XmlFile xmlHostKeyFile = new XmlFile(hostKeyFile); + key = (HostKey) xmlHostKeyFile.read(); + } else { + key = null; + } + cache.put(host, key); + } + return key; + } + + + /** + * Persists an SSH key to disk for the requested host. This effectively marks + * the requested key as trusted for all future connections to the host, until + * any future save attempt replaces this key. + * @param host the host the key is being saved for + * @param hostKey the key to be saved as the trusted key for this host + * @throws IOException on failure saving the key for the host + */ + public void saveHostKey(Computer host, HostKey hostKey) throws IOException { + XmlFile xmlHostKeyFile = new XmlFile(getSshHostKeyFile(host.getNode())); + xmlHostKeyFile.write(hostKey); + cache.put(host, hostKey); + } + + private File getSshHostKeyFile(Node node) throws IOException { + return new File(getNodeDirectory(node), "ssh-host-key.xml"); + } + + private File getNodeDirectory(Node node) throws IOException { + if (null == node) { + throw new IOException("Could not load key for the requested node"); + } + return new File(getNodesDirectory(), node.getNodeName()); + } + + private File getNodesDirectory() throws IOException { + // jenkins.model.Nodes#getNodesDirectory() is private, so we have to duplicate it here. + File nodesDir = new File(Jenkins.get().getRootDir(), "nodes"); + if (!nodesDir.exists() || !nodesDir.isDirectory()) { + throw new IOException("Nodes directory does not exist"); + } + return nodesDir; + } +}
src/main/java/hudson/plugins/ec2/ssh/verifiers/HostKey.java+103 −0 added@@ -0,0 +1,103 @@ +/* + * The MIT License + * + * Original work from ssh-slaves-plugin Copyright (c) 2016, Michael Clarke + * Modified work Copyright (c) 2020-, M Ramon Leon, CloudBees, Inc. + * Modified work: + * - Just the since annotation + + * 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 hudson.plugins.ec2.ssh.verifiers; + +import com.trilead.ssh2.KnownHosts; +import edu.umd.cs.findbugs.annotations.NonNull; + +import java.io.Serializable; +import java.util.Arrays; + +/** + * A representation of the SSH key provided by a remote host to verify itself + * and secure the initial setup of the SSH connection. + * @author Michael Clarke, M Ramon Leon + * @since TODO + */ +public final class HostKey implements Serializable { + + private static final long serialVersionUID = -3873284593211178494L; + + private final String algorithm; + private final byte[] key; + + public HostKey(@NonNull String algorithm, @NonNull byte[] key) { + super(); + this.algorithm = algorithm; + this.key = key.clone(); + } + + /** + * Get the algorithm used during key generation. + * @return the algorithm used to generate the key, such as ssh-rsa. + */ + @NonNull + public String getAlgorithm() { + return algorithm; + } + + /** + * Get the unencoded content of the key, without any algorithm prefix. + * @return a byte representation of the raw key value. + */ + @NonNull + public byte[] getKey() { + return key.clone(); + } + + public String getFingerprint() { + return KnownHosts.createHexFingerprint(getAlgorithm(), getKey()); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((algorithm == null) ? 0 : algorithm.hashCode()); + result = prime * result + Arrays.hashCode(key); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + HostKey other = (HostKey) obj; + if (algorithm == null) { + if (other.algorithm != null) + return false; + } else if (!algorithm.equals(other.algorithm)) + return false; + if (!Arrays.equals(key, other.key)) + return false; + return true; + } +}
src/main/java/hudson/plugins/ec2/ssh/verifiers/NonVerifyingKeyVerificationStrategy.java+47 −0 added@@ -0,0 +1,47 @@ +/* + * The MIT License + * + * Copyright (c) 2020-, M Ramon Leon, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package hudson.plugins.ec2.ssh.verifiers; + +import hudson.model.TaskListener; +import hudson.plugins.ec2.EC2Cloud; +import hudson.plugins.ec2.EC2Computer; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * This strategy doesn't perform any kind of verification + * @author M Ramon Leon + * @since TODO + */ +public class NonVerifyingKeyVerificationStrategy extends SshHostKeyVerificationStrategy { + private static final Logger LOGGER = Logger.getLogger(NonVerifyingKeyVerificationStrategy.class.getName()); + + @Override + public boolean verify(EC2Computer computer, HostKey hostKey, TaskListener listener) throws IOException { + EC2Cloud.log(LOGGER, Level.INFO, computer.getListener(), String.format("No SSH key verification (%s %s) for connections to %s", hostKey.getAlgorithm(), hostKey.getFingerprint(), computer.getName())); + return true; + } +}
src/main/java/hudson/plugins/ec2/ssh/verifiers/SshHostKeyVerificationAdministrativeMonitor.java+146 −0 added@@ -0,0 +1,146 @@ +/* + * The MIT License + * + * Copyright (c) 2020-, M Ramon Leon, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package hudson.plugins.ec2.ssh.verifiers; + +import hudson.Extension; +import hudson.model.AdministrativeMonitor; +import hudson.plugins.ec2.EC2Cloud; +import hudson.plugins.ec2.HostKeyVerificationStrategyEnum; +import hudson.plugins.ec2.PluginImpl; +import hudson.plugins.ec2.SlaveTemplate; +import hudson.slaves.Cloud; +import jenkins.model.Jenkins; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.interceptor.RequirePOST; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.ListIterator; +import java.util.stream.Collectors; + +@Extension +public class SshHostKeyVerificationAdministrativeMonitor extends AdministrativeMonitor { + private final static int MAX_TEMPLATES_FOUND = 5; + + List<String> veryInsecureTemplates = new ArrayList<>(MAX_TEMPLATES_FOUND); + List<String> insecureTemplates = new ArrayList<>(MAX_TEMPLATES_FOUND); + + @Override + public String getDisplayName() { + return Messages.AdminMonitor_DisplayName(); + } + + public String getVeryInsecureTemplates() { + return veryInsecureTemplates.stream().collect(Collectors.joining(", ")); + } + + public String getInsecureTemplates() { + return insecureTemplates.stream().collect(Collectors.joining(", ")); + } + + public boolean showVeryInsecureTemplates() { + return !veryInsecureTemplates.isEmpty(); + } + + public boolean showInsecureTemplates() { + PluginImpl plugin = PluginImpl.get(); + // On some tests it may be null + if (plugin == null) { + return true; + } + + Instant whenDismissed = Instant.ofEpochMilli(plugin.getDismissInsecureMessages()); // if not dismissed, it is EPOCH + return (whenDismissed.equals(Instant.EPOCH) || Instant.now().isBefore(whenDismissed)) && !insecureTemplates.isEmpty(); + } + + /** + * Let's activate the monitor if we find insecure templates, instead of look for running computers. + * @return true if the monitor is activated + */ + @Override + public boolean isActivated() { + boolean maxTemplatesReached = false; + + ListIterator<Cloud> cloudIterator = Jenkins.get().clouds.listIterator(); + + // Let's clear the previously calculated wrong templates to populate the lists with them again + veryInsecureTemplates.clear(); + insecureTemplates.clear(); + + while (cloudIterator.hasNext() && !maxTemplatesReached) { + Cloud cloud = cloudIterator.next(); + if (cloud instanceof EC2Cloud) { + maxTemplatesReached = gatherInsecureTemplate((EC2Cloud) cloud); + } + } + + if (showInsecureTemplates() || showVeryInsecureTemplates()) { + return true; + } else { + return false; + } + } + + private boolean gatherInsecureTemplate(EC2Cloud cloud) { + List<SlaveTemplate> templates = cloud.getTemplates(); + for (SlaveTemplate template : templates) { + // It's only for unix templates + if (!template.isUnixSlave()) { + continue; + } + + HostKeyVerificationStrategyEnum strategy = template.getHostKeyVerificationStrategy(); + if (veryInsecureTemplates.size() < MAX_TEMPLATES_FOUND && strategy.equals(HostKeyVerificationStrategyEnum.OFF)) { + veryInsecureTemplates.add(template.getDisplayName()); + } else if (insecureTemplates.size() < MAX_TEMPLATES_FOUND && (!strategy.equals(HostKeyVerificationStrategyEnum.CHECK_NEW_HARD))) { + // it is check-new-soft or accept-new + insecureTemplates.add(template.getDisplayName()); + } + + // stop collecting the status of the computers, we already have 5 each type + if (veryInsecureTemplates.size() >= MAX_TEMPLATES_FOUND || insecureTemplates.size() >= MAX_TEMPLATES_FOUND) { + return true; + } + } + + return false; + } + + @RequirePOST + public HttpResponse doAct(@QueryParameter String dismiss, @QueryParameter String dismissAllMessages) throws IOException { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + if (dismiss != null) { + PluginImpl.get().saveDismissInsecureMessages(System.currentTimeMillis()); + } + + if (dismissAllMessages != null) { + disable(true); + } + return HttpResponses.forwardToPreviousPage(); + } +}
src/main/java/hudson/plugins/ec2/ssh/verifiers/SshHostKeyVerificationStrategy.java+148 −0 added@@ -0,0 +1,148 @@ +/* + * The MIT License + * + * Original work from ssh-slaves-plugin Copyright (c) 2016, Michael Clarke + * Modified work Copyright (c) 2020-, M Ramon Leon, CloudBees, Inc. + * Modified work: + * - getHostKeyFromConsole method and called methods + * + * 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 hudson.plugins.ec2.ssh.verifiers; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.model.Describable; +import hudson.model.Descriptor; +import hudson.model.TaskListener; +import hudson.plugins.ec2.EC2Cloud; +import hudson.plugins.ec2.EC2Computer; +import hudson.plugins.ec2.InstanceState; +import jenkins.model.Jenkins; + +import java.util.Base64; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A method for verifying the host key provided by the remote host during the + * initiation of each connection. + * + * @author Michael Clarke + * @since TODO + */ +public abstract class SshHostKeyVerificationStrategy implements Describable<SshHostKeyVerificationStrategy> { + + @Override + public SshHostKeyVerificationStrategyDescriptor getDescriptor() { + return (SshHostKeyVerificationStrategyDescriptor) Jenkins.get().getDescriptorOrDie(getClass()); + } + + /** + * Check if the given key is valid for the host identifier. + * @param computer the computer this connection is being initiated for + * @param hostKey the key that was transmitted by the remote host for the current connection. This is the key + * that should be checked to see if we trust it by the current verifier. + * @param listener the connection listener to write any output log to + * @return whether the provided HostKey is trusted and the current connection can therefore continue. + * @since 1.12 + */ + public abstract boolean verify(EC2Computer computer, HostKey hostKey, TaskListener listener) throws Exception; + + public static abstract class SshHostKeyVerificationStrategyDescriptor extends Descriptor<SshHostKeyVerificationStrategy> { + } + + /** + * Get the host key printed out in the console. + * @param computer + * @param serverHostKeyAlgorithm + * @return the hostkey found in the console, null if the console is blank, a non-null HostKey with the algorithm + * but an empty array as the key if the console is not blank and the key for such an algorithm couldn't be found. + */ + @Nullable + HostKey getHostKeyFromConsole(@NonNull final Logger logger, @NonNull final EC2Computer computer, @NonNull final String serverHostKeyAlgorithm) { + HostKey key; + TaskListener listener = computer.getListener(); + + try { + if(!computer.getState().equals(InstanceState.RUNNING)) { + EC2Cloud.log(logger, Level.INFO, listener, "The instance " + computer.getName() + " is not running, waiting to validate the key against the console"); + } + } catch (InterruptedException e) { + return null; + } + + String line = getLineWithKey(logger, computer, serverHostKeyAlgorithm); + if (line != null && line.length() > 0) { + key = getKeyFromLine(logger, line, listener); + } else if (line != null) { + key = new HostKey(serverHostKeyAlgorithm, new byte[]{}); + } else { + key = null; + } + + return key; + } + + /** + * Get the line with the key for such an algorithm + * @param logger the logger to print the messages + * @param computer the computer + * @param serverHostKeyAlgorithm the algorithm to search for + * @return the line where the key for the algorithm is on, null if the console is blank, "" if the console is not + * blank and the line is not found. + */ + @CheckForNull + String getLineWithKey(@NonNull final Logger logger, @NonNull final EC2Computer computer, @NonNull final String serverHostKeyAlgorithm) { + String line = null; + String console = computer.getDecodedConsoleOutput(); + if (console == null) { + // The instance is running and the console is blank + EC2Cloud.log(logger, Level.INFO, computer.getListener(), "The instance " + computer.getName() + " has a blank console. Maybe the console is yet not available. If enough time has passed, consider changing the key verification strategy or the AMI used by one printing out the host key in the instance console"); + return null; + } + + try { + int start = console.indexOf(serverHostKeyAlgorithm); + if (start > -1) { + int end = console.indexOf('\n', start); + line = console.substring(start, end); + } else { + // The instance printed on the console but the key was not printed with the expected format + EC2Cloud.log(logger, Level.INFO, computer.getListener(), String.format("The instance %s didn't print the host key. Expected a line starting with: \"%s\"", computer.getName(), serverHostKeyAlgorithm)); + return ""; + } + } catch (IllegalArgumentException ignored) { + } + return line; + } + + @CheckForNull + HostKey getKeyFromLine(@NonNull final Logger logger, @NonNull final String line, @Nullable final TaskListener listener) { + String[] parts = line.split(" "); + if (parts.length > 2) { + // The public SSH key in the console is Base64 encoded + return new HostKey(parts[0], Base64.getDecoder().decode(parts[1])); + } else { + EC2Cloud.log(logger, Level.INFO, listener, String.format("The line with the key doesn't have the required format. Found: \"%s\". Expected a line with this text: \"ALGORITHM THEHOSTKEY\", example: \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJbvbEIoY3tqKwkeRW/L1FnbCLLp8a1TwSOyZHKJqFFR \"", line)); + return null; + } + } +}
src/main/java/hudson/plugins/ec2/WindowsData.java+17 −1 modified@@ -15,9 +15,10 @@ public class WindowsData extends AMITypeData { private final boolean useHTTPS; private final String bootDelay; private final boolean specifyPassword; + private final Boolean allowSelfSignedCertificate; //Boolean to allow nulls when the saved template doesn't have the field @DataBoundConstructor - public WindowsData(String password, boolean useHTTPS, String bootDelay, boolean specifyPassword) { + public WindowsData(String password, boolean useHTTPS, String bootDelay, boolean specifyPassword, boolean allowSelfSignedCertificate) { this.password = Secret.fromString(password); this.useHTTPS = useHTTPS; this.bootDelay = bootDelay; @@ -26,6 +27,12 @@ public WindowsData(String password, boolean useHTTPS, String bootDelay, boolean if (!specifyPassword && !this.password.getPlainText().isEmpty()) { specifyPassword = true; } + this.allowSelfSignedCertificate = allowSelfSignedCertificate; + } + + @Deprecated + public WindowsData(String password, boolean useHTTPS, String bootDelay, boolean specifyPassword) { + this(password, useHTTPS, bootDelay, specifyPassword, true); } public WindowsData(String password, boolean useHTTPS, String bootDelay) { @@ -66,6 +73,10 @@ public int getBootDelayInMillis() { } } + public boolean isAllowSelfSignedCertificate(){ + return allowSelfSignedCertificate == null || allowSelfSignedCertificate; + } + @Extension public static class DescriptorImpl extends Descriptor<AMITypeData> { @Override @@ -98,6 +109,11 @@ public boolean equals(Object obj) { return false; } else if (!password.equals(other.password)) return false; + if (allowSelfSignedCertificate == null) { + if (other.allowSelfSignedCertificate != null) + return false; + } else if (!allowSelfSignedCertificate.equals(other.allowSelfSignedCertificate)) + return false; return useHTTPS == other.useHTTPS && specifyPassword == other.specifyPassword; } }
src/main/java/hudson/plugins/ec2/win/EC2WindowsLauncher.java+27 −7 modified@@ -14,12 +14,14 @@ import hudson.Util; import hudson.os.WindowsUtil; +import java.io.EOFException; import java.io.IOException; import java.io.OutputStream; import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; +import hudson.slaves.OfflineCause; import javax.annotation.Nonnull; import jenkins.model.Jenkins; @@ -30,6 +32,8 @@ import com.amazonaws.services.ec2.model.GetPasswordDataRequest; import com.amazonaws.services.ec2.model.GetPasswordDataResult; +import javax.net.ssl.SSLException; + public class EC2WindowsLauncher extends EC2ComputerLauncher { private static final String AGENT_JAR = "remoting.jar"; @@ -50,7 +54,7 @@ protected void launchScript(EC2Computer computer, TaskListener listener) throws } final WinConnection connection = connectToWinRM(computer, node, template, logger); - + try { String initScript = node.initScript; String tmpDir = (node.tmpDir != null && !node.tmpDir.equals("") ? WindowsUtil.quoteArgument(Util.ensureEndsWith(node.tmpDir,"\\")) @@ -104,6 +108,13 @@ public void onClosed(Channel channel, IOException cause) { connection.close(); } }); + } catch (EOFException eof) { + // When we launch java with connection.execute(launchString... it keeps running, but if java is not installed + //the computer.setChannel fails with EOFException because the stream is already closed. It fails on + // setChannel - build - negotiate - is.read() == -1. Let's print a clear message to help diagnose the problem + // In other case you see a EOFException which gives you few clues about the problem. + logger.println("The stream with the java process on the instance was closed. Maybe java is not installed there."); + eof.printStackTrace(logger); } catch (Throwable ioe) { logger.println("Ouch:"); ioe.printStackTrace(logger); @@ -126,6 +137,8 @@ private WinConnection connectToWinRM(EC2Computer computer, EC2AbstractSlave node boolean alreadyBooted = (startTime - node.getCreatedTime()) > TimeUnit.MINUTES.toMillis(3); WinConnection connection = null; while (true) { + boolean allowSelfSignedCertificate = node.isAllowSelfSignedCertificate(); + try { long waitTime = System.currentTimeMillis() - startTime; if (waitTime > timeout) { @@ -137,8 +150,9 @@ private WinConnection connectToWinRM(EC2Computer computer, EC2AbstractSlave node Instance instance = computer.updateInstanceDescription(); String host = EC2HostAddressProvider.windows(instance, template.connectionStrategy); - if ("0.0.0.0".equals(host)) { - logger.println("Invalid host 0.0.0.0, your host is most likely waiting for an ip address."); + // Check when host is null or we will keep trying and receiving a hostname cannot be null forever. + if (host == null || "0.0.0.0".equals(host)) { + logger.println("Invalid host (null or 0.0.0.0). Your host is most likely waiting for an IP address."); throw new IOException("goto sleep"); } @@ -162,15 +176,15 @@ private WinConnection connectToWinRM(EC2Computer computer, EC2AbstractSlave node logger.println("WARNING: For password retrieval remote admin must be Administrator, ignoring user provided value"); } logger.println("Connecting to " + "(" + host + ") with WinRM as Administrator"); - connection = new WinConnection(host, "Administrator", password); + connection = new WinConnection(host, "Administrator", password, allowSelfSignedCertificate); } else { //password Specified logger.println("Connecting to " + "(" + host + ") with WinRM as " + node.getRemoteAdmin()); - connection = new WinConnection(host, node.getRemoteAdmin(), node.getAdminPassword().getPlainText()); + connection = new WinConnection(host, node.getRemoteAdmin(), node.getAdminPassword().getPlainText(), allowSelfSignedCertificate); } connection.setUseHTTPS(node.isUseHTTPS()); } - if (!connection.ping()) { + if (!connection.pingFailingIfSSHHandShakeError()) { logger.println("Waiting for WinRM to come up. Sleeping 10s."); Thread.sleep(sleepBetweenAttempts); continue; @@ -182,7 +196,7 @@ private WinConnection connectToWinRM(EC2Computer computer, EC2AbstractSlave node Thread.sleep(node.getBootDelay()); alreadyBooted = true; logger.println("WinRM should now be ok on " + node.getDisplayName()); - if (!connection.ping()) { + if (!connection.pingFailingIfSSHHandShakeError()) { logger.println("WinRM not yet up. Sleeping 10s."); Thread.sleep(sleepBetweenAttempts); continue; @@ -192,6 +206,12 @@ private WinConnection connectToWinRM(EC2Computer computer, EC2AbstractSlave node logger.println("Connected with WinRM."); return connection; // successfully connected } catch (IOException e) { + if (e instanceof SSLException) { + // To avoid reconnecting continuously + computer.setTemporarilyOffline(true, OfflineCause.create(Messages._OfflineCause_SSLException())); + // avoid waiting and trying again, this connection needs human intervention to change the certificate + throw new AmazonClientException("The SSL connection failed while negotiating SSL", e); + } logger.println("Waiting for WinRM to come up. Sleeping 10s."); Thread.sleep(sleepBetweenAttempts); }
src/main/java/hudson/plugins/ec2/win/SelfSignedCertificateAllowedMonitor.java+120 −0 added@@ -0,0 +1,120 @@ +/* + * The MIT License + * + * Copyright (c) 2020-, M Ramon Leon, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package hudson.plugins.ec2.win; + +import hudson.Extension; +import hudson.model.AdministrativeMonitor; +import hudson.plugins.ec2.AMITypeData; +import hudson.plugins.ec2.EC2Cloud; +import hudson.plugins.ec2.PluginImpl; +import hudson.plugins.ec2.SlaveTemplate; +import hudson.plugins.ec2.WindowsData; +import hudson.slaves.Cloud; +import jenkins.model.Jenkins; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.interceptor.RequirePOST; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.ListIterator; +import java.util.stream.Collectors; + +@Extension +public class SelfSignedCertificateAllowedMonitor extends AdministrativeMonitor { + private final static int MAX_TEMPLATES_FOUND = 5; + + List<String> insecureTemplates = new ArrayList<>(MAX_TEMPLATES_FOUND); + + @Override + public String getDisplayName() { + return Messages.AdminMonitor_DisplayName(); + } + + public String getSelfSignedCertAllowedTemplates() { + return String.join(", ", insecureTemplates); + } + + /** + * Let's activate the monitor if we find insecure (self-signed certificate allowed) windows templates, instead of + * look for running computers. + * @return true if the monitor is activated + */ + @Override + public boolean isActivated() { + boolean maxTemplatesReached = false; + + ListIterator<Cloud> cloudIterator = Jenkins.get().clouds.listIterator(); + + // Let's clear the previously calculated wrong templates to populate the lists with them again + insecureTemplates.clear(); + + while (cloudIterator.hasNext() && !maxTemplatesReached) { + Cloud cloud = cloudIterator.next(); + if (cloud instanceof EC2Cloud) { + maxTemplatesReached = gatherInsecureTemplate((EC2Cloud) cloud); + } + } + + return !insecureTemplates.isEmpty(); + } + + private boolean gatherInsecureTemplate(EC2Cloud cloud) { + List<SlaveTemplate> templates = cloud.getTemplates(); + for (SlaveTemplate template : templates) { + // It's only for window templates + if (!template.isWindowsSlave()) { + continue; + } + + AMITypeData amiTypeData = template.getAmiType(); + if (insecureTemplates.size() < MAX_TEMPLATES_FOUND && amiTypeData.isWindows() && ((WindowsData)amiTypeData).isAllowSelfSignedCertificate()) { + // it is insecure + insecureTemplates.add(template.getDisplayName()); + } + + // stop collecting the status of the computers, we already have the max allowed + if (insecureTemplates.size() >= MAX_TEMPLATES_FOUND) { + return true; + } + } + + return false; + } + + @RequirePOST + public HttpResponse doAct(@QueryParameter String dismiss, @QueryParameter String dismissAllMessages) throws IOException { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + if (dismiss != null) { + PluginImpl.get().saveDismissInsecureMessages(System.currentTimeMillis()); + } + + if (dismissAllMessages != null) { + disable(true); + } + return HttpResponses.forwardToPreviousPage(); + } +}
src/main/java/hudson/plugins/ec2/win/WinConnection.java+23 −1 modified@@ -19,6 +19,7 @@ import com.hierynomus.mssmb2.SMB2ShareAccess; import com.hierynomus.mssmb2.SMB2CreateDisposition; +import javax.net.ssl.SSLException; import java.util.logging.Level; import java.util.logging.Logger; @@ -34,17 +35,24 @@ public class WinConnection { private boolean useHTTPS; private static final int TIMEOUT=8000; //8 seconds + private boolean allowSelfSignedCertificate; + @Deprecated public WinConnection(String host, String username, String password) { + this(host, username, password, true); + } + + public WinConnection(String host, String username, String password, boolean allowSelfSignedCertificate) { this.host = host; this.username = username; this.password = password; this.smbclient = new SMBClient(); this.authentication = new AuthenticationContext(username, password.toCharArray(), null); + this.allowSelfSignedCertificate = allowSelfSignedCertificate; } public WinRM winrm() { - WinRM winrm = new WinRM(host, username, password); + WinRM winrm = new WinRM(host, username, password, allowSelfSignedCertificate); winrm.setUseHTTPS(useHTTPS); return winrm; } @@ -101,7 +109,16 @@ private static String toFilePath(String path) { return path.substring(3); } + // keep this method for compatibility, not used in this plugin anymore public boolean ping() { + try { + return pingFailingIfSSHHandShakeError(); + } catch (IOException ignored) { + return false; + } + } + + public boolean pingFailingIfSSHHandShakeError() throws IOException { log.log(Level.FINE, "checking SMB connection to " + host); try { Socket socket=new Socket(); @@ -114,6 +131,11 @@ public boolean ping() { return true; } catch (Exception e) { log.log(Level.WARNING, "Failed to verify connectivity to Windows slave", e); + if (e instanceof SSLException) { + throw e; + } else if (e.getCause() instanceof SSLException) { + throw (SSLException) e.getCause(); + } return false; } }
src/main/java/hudson/plugins/ec2/win/winrm/WinRMClient.java+21 −4 modified@@ -75,12 +75,20 @@ public class WinRMClient { private final ThreadLocal<BasicAuthCache> authCache = new ThreadLocal<BasicAuthCache>(); private boolean useHTTPS; private BasicCredentialsProvider credsProvider; - + private boolean allowSelfSignedCertificate; + + @Deprecated public WinRMClient(URL url, String username, String password) { + this(url, username, password, false); + } + + public WinRMClient(URL url, String username, String password, boolean allowSelfSignedCertificate) { this.url = url; this.username = username; this.password = password; this.factory = new RequestFactory(url); + this.allowSelfSignedCertificate = allowSelfSignedCertificate; + setupHTTPClient(); } @@ -194,9 +202,18 @@ private HttpClient buildHTTPClient() { } if (useHTTPS) { try { - SSLConnectionSocketFactory sslConnectionFactory = new SSLConnectionSocketFactory( - new SSLContextBuilder().loadTrustMaterial(null, new TrustSelfSignedStrategy()).build(), - NoopHostnameVerifier.INSTANCE); + SSLConnectionSocketFactory sslConnectionFactory; + + if (allowSelfSignedCertificate) { + sslConnectionFactory = new SSLConnectionSocketFactory( + new SSLContextBuilder().loadTrustMaterial(null, new TrustSelfSignedStrategy()).build(), + NoopHostnameVerifier.INSTANCE); + log.log(Level.FINE, "Allowing self-signed certificates"); + } else { + sslConnectionFactory = SSLConnectionSocketFactory.getSystemSocketFactory(); + log.log(Level.FINE, "Using system socket factory"); + } + builder.setSSLSocketFactory(sslConnectionFactory); Lookup<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create() .register("https", sslConnectionFactory)
src/main/java/hudson/plugins/ec2/win/winrm/WinRM.java+10 −3 modified@@ -12,17 +12,24 @@ public class WinRM { private final String username; private final String password; private int timeout = 60; + private boolean allowSelfSignedCertificate; private boolean useHTTPS; + @Deprecated public WinRM(String host, String username, String password) { + this(host, username, password, true); + } + + public WinRM(String host, String username, String password, boolean allowSelfSignedCertificate) { this.host = host; this.username = username; this.password = password; + this.allowSelfSignedCertificate = allowSelfSignedCertificate; } public void ping() throws IOException { - final WinRMClient client = new WinRMClient(buildURL(), username, password); + final WinRMClient client = new WinRMClient(buildURL(), username, password, allowSelfSignedCertificate); client.setTimeout(secToDuration(timeout)); client.setUseHTTPS(isUseHTTPS()); try { @@ -36,7 +43,7 @@ public void ping() throws IOException { } public WindowsProcess execute(String commandLine) { - final WinRMClient client = new WinRMClient(buildURL(), username, password); + final WinRMClient client = new WinRMClient(buildURL(), username, password, allowSelfSignedCertificate); client.setTimeout(secToDuration(timeout)); client.setUseHTTPS(isUseHTTPS()); try { @@ -95,7 +102,7 @@ public void setTimeout(int timeout) { * http://tools.ietf.org/html/rfc2445#section-4.3.6 # @param [Fixnum] seconds The amount of seconds for this * duration * - * @param timeout + * @param seconds * @return */ private static String secToDuration(int seconds) {
src/main/resources/hudson/plugins/ec2/SlaveTemplate/config.jelly+4 −0 modified@@ -196,6 +196,10 @@ THE SOFTWARE. <f:checkbox /> </f:entry> + <f:entry title="${%Host Key Verification Strategy}" field="hostKeyVerificationStrategy"> + <f:select default="${descriptor.defaultHostKeyVerificationStrategy}"/> + </f:entry> + <f:entry title="${%Maximum Total Uses}" field="maxTotalUses"> <f:textbox default="-1"/> </f:entry>
src/main/resources/hudson/plugins/ec2/SlaveTemplate/help-amiType.html+5 −5 modified@@ -19,19 +19,19 @@ <li>WinRM should be enabled with the following commands (for more information see: <a href="http://support.microsoft.com/kb/555966">Microsoft article 555966</a>): <ul> <li>winrm quickconfig</li> - <li>winrm set winrm/config/service/Auth @{Basic="true"}</li> - <li>winrm set winrm/config/service @{AllowUnencrypted="true"}</li> - <li>winrm set winrm/config/winrs @{MaxMemoryPerShellMB="1024"}</li> + <li>winrm set winrm/config/service/Auth '@{Basic="true"}'</li> + <li>winrm set winrm/config/service '@{AllowUnencrypted="true"}'</li> + <li>winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="1024"}'</li> <li>For https: <ul> <li><a href="http://www.hansolav.net/blog/SelfsignedSSLCertificatesOnIIS7AndCommonNames.aspx">Generate a windows certificate</a></li> <li><a href="http://support.microsoft.com/kb/2019527">Install the certificate</a></li> - <li>winrm create winrm/config/Listener?Address=*+Transport=HTTPS @{Hostname="HOSTNAME"; CertificateThumbprint="THUMBPRINT"}</li> + <li>winrm create winrm/config/Listener?Address=*+Transport=HTTPS '@{Hostname="HOSTNAME"; CertificateThumbprint="THUMBPRINT"}'</li> </ul> </li> </ul> </li> </ul> Finally make sure to set the username to Administrator and enter the administrator password. -</div> \ No newline at end of file +</div>
src/main/resources/hudson/plugins/ec2/SlaveTemplate/help-hostKeyVerificationStrategy.html+43 −0 added@@ -0,0 +1,43 @@ +<!-- +The MIT License + +Copyright (c) 2015, CloudBees, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +--> +<div> + How the SSH instance key is going to be verified when connecting to <i>Unix (AMI Type)</i> instances. + <ul> + <li><b><i>check-new-hard</i></b>: Check the key presented by the instance against the instance console and stores it to check subsequent connections. If the key is not printed on the console, the connection is not trusted. This is the default behavior for new AMIs.</li> + <li><b><i>check-new-soft</i></b>: Check the key against the instance console and stores it to check subsequent connections. If the key is not printed on the console, the connection is trusted anyway. This is the default behavior for existing AMIs (upgrading from a previous plugin version). This avoids future attacks but cannot guarantee the instance is the right one if a man-in-the-middle attack has already been committed.</i></b></li> + <li><b><i>accept-new</i></b>: Accept the key on first connection and stores it to check subsequent connections. This doesn't try to check the key against the console as the <i>check-new-soft</i> strategy does.</li> + <li><b><i>off</i></b>: Don't check the host key on any connection.</li> + </ul> + + If the <i>Connect by SSH Process</i> field is checked, the equivalences with the <i>StrictHostKeyChecking</i> flag are: + <ul> + <li><b><i>check-new-hard = yes</i></b></li> + <li><b><i>check-new-soft = accept-new</i></b></li> + <li><b><i>accept-new = accept-new</i></b></li> + <li><b><i>off = off</i></b></li> + </ul> + + <i><strong>Note: </strong></i>With the <i>check-new-hard</i> and <i>check-new-soft</i> strategies you may need to increase the <i>Launch Timeout in seconds</i> because it will now + take until the instance console is ready, which could be several minutes. If the field is blank, it means there is no time-out which will wait indefinitely for the console. +</div> \ No newline at end of file
src/main/resources/hudson/plugins/ec2/ssh/verifiers/Messages.properties+3 −0 added@@ -0,0 +1,3 @@ +OfflineCause.SSHKeyCheckFailed=The instance SSH key was unexpected or impossible to check + +AdminMonitor.DisplayName=EC2 Weak Host Key Verification Strategies Monitor \ No newline at end of file
src/main/resources/hudson/plugins/ec2/ssh/verifiers/SshHostKeyVerificationAdministrativeMonitor/message.jelly+63 −0 added@@ -0,0 +1,63 @@ +<!-- +The MIT License + +Copyright (c) 2018, CloudBees, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +--> +<?jelly escape-by-default='true'?> +<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form"> + <div class="alert alert-warning"> + + <j:if test="${it.showVeryInsecureTemplates()}"> + <strong>${%Critical.Title}</strong> + <p /> + + ${%Critical.AffectedTemplates(it.veryInsecureTemplates)} + <p /> + + ${%Critical.Explanation(rootURL)} + <p /> + </j:if> + + <j:if test="${it.showInsecureTemplates()}"> + <strong>${%Unsafe.Title}</strong> + <form method="post" action="${rootURL}/${it.url}/act"> + <f:submit name="dismiss" value="${%Dismiss these messages}"/> + </form> + <p /> + + ${%Unsafe.AffectedTemplates(it.insecureTemplates)} + <p /> + + ${%Unsafe.Explanation(rootURL)} + <p /> + + ${%Unsafe.DismissTheseMessages} + <p /> + </j:if> + + <p /> + ${%DismissAll.Message} + <form method="post" action="${rootURL}/${it.url}/act"> + <f:submit name="dismissAllMessages" value="${%Dismiss all messages}"/> + </form> + <p /> + </div> +</j:jelly>
src/main/resources/hudson/plugins/ec2/ssh/verifiers/SshHostKeyVerificationAdministrativeMonitor/message.properties+22 −0 added@@ -0,0 +1,22 @@ +Critical.Title=Critically Unsafe Linux EC2 templates found +Critical.AffectedTemplates=There are some EC2 templates using the <i>OFF</i> host key strategy, for example: <i>{0}</i>. +Critical.Explanation=The instances created using those templates are not safe from a <a target="_blank" href="https://en.wikipedia.org/wiki/Man-in-the-middle_attack"><acronym title="man-in-the-middle">MitM</acronym> attack</a>. \ + Please consider updating these templates to use a more secure template. Visit \ + <a href="{0}/configure">Jenkins configure page</a> and select a more secure strategy on the \ + <i>Host Key Verification Strategy</i> field of the affected templates (click <i>Advanced...</i> button to see the field). + + + +Unsafe.Title=Unsafe Linux EC2 templates found +Unsafe.AffectedTemplates=There are some EC2 templates using the <i>accept-new</i> or <i>check-new-soft</i> host key strategies, for example: <i>{0}</i>. +Unsafe.Explanation=These strategies check the host key to ensure your connection is safe from a <a target="_blank" href="https://en.wikipedia.org/wiki/Man-in-the-middle_attack"><acronym title="man-in-the-middle">MitM</acronym> attack</a>. \ + However, if such an attack was already perpetrated, the verification is useless. To be absolutely safe, consider using the <i>check-new-hard</i> \ + strategy (your instance should print out the key on the console to be checked). Visit \ + <a href="{0}/configure">Jenkins configure page</a> and select a more secure strategy on the \ + <i>Host Key Verification Strategy</i> field of the affected templates (click <i>Advanced...</i> button to see the field). +Unsafe.DismissTheseMessages=If you are sure your system is safe from a <a target="_blank" href="https://en.wikipedia.org/wiki/Man-in-the-middle_attack"><acronym title="man-in-the-middle">MitM</acronym> attack</a> \ + and you don't worry about trusting instance identities the first time you connect to them without checking the key provided, you can <i>dismiss these messages</i> in the \ + future. But be aware that if a <a target="_blank" href="https://en.wikipedia.org/wiki/Man-in-the-middle_attack"><acronym title="man-in-the-middle">MitM</acronym> attack</a> is successfully \ + perpetrated, your communication with these instances will be on risk. + +DismissAll.Message=If you don't want to see any message about the security concerning the host key verification on EC2 instances, you can <i>dismiss all messages</i>. \ No newline at end of file
src/main/resources/hudson/plugins/ec2/WindowsData/config.jelly+4 −1 modified@@ -30,7 +30,10 @@ THE SOFTWARE. </f:entry> </f:optionalBlock> <f:entry title="${%Use HTTPS}" field="useHTTPS"> - <f:checkbox /> + <f:checkbox default="true"/> + </f:entry> + <f:entry title="${%Allow Self Signed Certificate}" field="allowSelfSignedCertificate" > + <f:checkbox default="false"/> </f:entry> <f:entry title="${%Boot Delay}" field="bootDelay"> <f:textbox />
src/main/resources/hudson/plugins/ec2/WindowsData/help-selfSignedCertificate.html+3 −0 added@@ -0,0 +1,3 @@ +<div> +Enable this option to allow Jenkins to connect to instances with self-signed certificates. +</div>
src/main/resources/hudson/plugins/ec2/win/Messages.properties+2 −0 added@@ -0,0 +1,2 @@ +AdminMonitor.DisplayName=EC2 Windows Self-Signed Certificate Allowed Monitor +OfflineCause.SSLException=The instance SSL Key check failed
src/main/resources/hudson/plugins/ec2/win/SelfSignedCertificateAllowedMonitor/message.jelly+42 −0 added@@ -0,0 +1,42 @@ +<!-- +The MIT License + +Copyright (c) 2020, CloudBees, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +--> +<?jelly escape-by-default='true'?> +<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form"> + <div class="alert alert-warning"> + + <strong>${%Title}</strong> + <p /> + + ${%AffectedTemplates(it.selfSignedCertAllowedTemplates)} + <p /> + + ${%Explanation(rootURL)} + <p /> + + <form method="post" action="${rootURL}/${it.url}/act"> + <f:submit name="dismiss" value="${%Dismiss}"/> + </form> + <p /> + </div> +</j:jelly>
src/main/resources/hudson/plugins/ec2/win/SelfSignedCertificateAllowedMonitor/message.properties+10 −0 added@@ -0,0 +1,10 @@ +Title=Unsafe Windows EC2 templates found +AffectedTemplates=There are some Windows EC2 templates allowing self-signed certificates, for example: <i>{0}</i>. +Explanation=The instances created using those templates are not safe from a <a target="_blank" href="https://en.wikipedia.org/wiki/Man-in-the-middle_attack"><acronym title="man-in-the-middle">MitM</acronym> attack</a>. \ + Please consider updating these templates to avoid trusting self-signed certificates and ensure the \ + certificate used by the instance is issued by a trusted Certification Authority. Visit \ + <a href="{0}/configure">Jenkins configure page</a> and uncheck the <i>Allow Self Signed Certificate</i> \ + field of the affected templates. In addition make sure any non-terminable node is updated as well. \ + There are many ways to obtain a certificate, such as <a href="https://letsencrypt.org/getting-started/">Let''s Encrypt</a>, \ + which allows you to get a free certificate, or <a href="https://aws.amazon.com/kms/">AWS Key Management Service (KMS)</a>, which is \ + an AWS solution.
src/test/java/hudson/plugins/ec2/SlaveTemplateTest.java+54 −4 modified@@ -104,7 +104,7 @@ public void testConfigRoundtrip() throws Exception { r.submit(r.createWebClient().goTo("configure").getFormByName("config")); SlaveTemplate received = ((EC2Cloud) r.jenkins.clouds.iterator().next()).getTemplate(description); - r.assertEqualBeans(orig, received, "ami,zone,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,useEphemeralDevices,useDedicatedTenancy,connectionStrategy"); + r.assertEqualBeans(orig, received, "ami,zone,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,useEphemeralDevices,useDedicatedTenancy,connectionStrategy,hostKeyVerificationStrategy"); } @Test @@ -128,7 +128,57 @@ public void testConfigRoundtripWithCustomConnectionStrategy() throws Exception { r.submit(r.createWebClient().goTo("configure").getFormByName("config")); SlaveTemplate received = ((EC2Cloud) r.jenkins.clouds.iterator().next()).getTemplate(description); - r.assertEqualBeans(orig, received, "ami,zone,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,useEphemeralDevices,useDedicatedTenancy,connectionStrategy"); + r.assertEqualBeans(orig, received, "ami,zone,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,useEphemeralDevices,useDedicatedTenancy,connectionStrategy,hostKeyVerificationStrategy"); + } + + @Test + public void testDefaultSSHHostKeyVerificationStrategy() throws Exception { + String ami = "ami1"; + String description = "foo ami"; + + List<EC2Tag> tags = new ArrayList<EC2Tag>(); + + SlaveTemplate orig = new SlaveTemplate(ami, EC2AbstractSlave.TEST_ZONE, null, "default", "foo", InstanceType.M1Large, false, "ttt", Node.Mode.NORMAL, description, "bar", "bbb", "aaa", "10", "fff", null, "-Xmx1g", false, "subnet 456", tags, null, 0, 0, null, "", true, false, false, "", false, "", false, false, false, ConnectionStrategy.PUBLIC_IP, -1, null); + + List<SlaveTemplate> templates = new ArrayList<SlaveTemplate>(); + templates.add(orig); + + AmazonEC2Cloud ac = new AmazonEC2Cloud("us-east-1", false, "abc", "us-east-1", "ghi", "3", templates, null, null); + r.jenkins.clouds.add(ac); + + r.submit(r.createWebClient().goTo("configure").getFormByName("config")); + SlaveTemplate received = ((EC2Cloud) r.jenkins.clouds.iterator().next()).getTemplate(description); + r.assertEqualBeans(orig, received, "ami,zone,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,useEphemeralDevices,useDedicatedTenancy,connectionStrategy,hostKeyVerificationStrategy"); + // For already existing strategies, the default is this one + assertEquals(HostKeyVerificationStrategyEnum.CHECK_NEW_SOFT, received.getHostKeyVerificationStrategy()); + } + + @Test + public void testConfigRoundtripWithCustomSSHHostKeyVerificationStrategy() throws Exception { + String ami = "ami1"; + String description = "foo ami"; + + EC2Tag tag1 = new EC2Tag("name1", "value1"); + EC2Tag tag2 = new EC2Tag("name2", "value2"); + List<EC2Tag> tags = new ArrayList<EC2Tag>(); + tags.add(tag1); + tags.add(tag2); + + // We check this one is set + final HostKeyVerificationStrategyEnum STRATEGY_TO_CHECK = HostKeyVerificationStrategyEnum.OFF; + + SlaveTemplate orig = new SlaveTemplate(ami, EC2AbstractSlave.TEST_ZONE, null, "default", "foo", InstanceType.M1Large, false, "ttt", Node.Mode.NORMAL, description, "bar", "bbb", "aaa", "10", "fff", null, "-Xmx1g", false, "subnet 456", tags, null, 0, 0, null, "", true, false, false, "", false, "", false, false, false, ConnectionStrategy.PUBLIC_IP, -1, null, STRATEGY_TO_CHECK); + + List<SlaveTemplate> templates = new ArrayList<SlaveTemplate>(); + templates.add(orig); + + AmazonEC2Cloud ac = new AmazonEC2Cloud("us-east-1", false, "abc", "us-east-1", "ghi", "3", templates, null, null); + r.jenkins.clouds.add(ac); + + r.submit(r.createWebClient().goTo("configure").getFormByName("config")); + SlaveTemplate received = ((EC2Cloud) r.jenkins.clouds.iterator().next()).getTemplate(description); + r.assertEqualBeans(orig, received, "ami,zone,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,useEphemeralDevices,useDedicatedTenancy,connectionStrategy,hostKeyVerificationStrategy"); + assertEquals(STRATEGY_TO_CHECK, received.getHostKeyVerificationStrategy()); } /** @@ -228,7 +278,7 @@ public void testSpotConfigWithFallback() throws Exception { r.submit(r.createWebClient().goTo("configure").getFormByName("config")); SlaveTemplate received = ((EC2Cloud) r.jenkins.clouds.iterator().next()).getTemplate(description); - r.assertEqualBeans(orig, received, "ami,zone,spotConfig,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,tags,connectionStrategy"); + r.assertEqualBeans(orig, received, "ami,zone,spotConfig,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,tags,connectionStrategy,hostKeyVerificationStrategy"); } /** @@ -257,7 +307,7 @@ public void testConfigRoundtripIamRole() throws Exception { r.submit(r.createWebClient().goTo("configure").getFormByName("config")); SlaveTemplate received = ((EC2Cloud) r.jenkins.clouds.iterator().next()).getTemplate(description); - r.assertEqualBeans(orig, received, "ami,zone,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,iamInstanceProfile,connectionStrategy"); + r.assertEqualBeans(orig, received, "ami,zone,description,remoteFS,type,jvmopts,stopOnTerminate,securityGroups,subnetId,iamInstanceProfile,connectionStrategy,hostKeyVerificationStrategy"); } @Test
src/test/java/hudson/plugins/ec2/ssh/verifiers/SshHostKeyVerificationStrategyTest.java+391 −0 added@@ -0,0 +1,391 @@ +package hudson.plugins.ec2.ssh.verifiers; + +import com.amazonaws.AmazonClientException; +import com.amazonaws.services.ec2.model.InstanceType; +import com.trilead.ssh2.Connection; +import com.trilead.ssh2.ServerHostKeyVerifier; +import hudson.model.Node; +import hudson.plugins.ec2.ConnectionStrategy; +import hudson.plugins.ec2.EC2AbstractSlave; +import hudson.plugins.ec2.EC2Computer; +import hudson.plugins.ec2.InstanceState; +import hudson.plugins.ec2.SlaveTemplate; +import hudson.plugins.ec2.util.ConnectionRule; +import hudson.slaves.NodeProperty; +import org.hamcrest.core.StringContains; +import org.junit.ClassRule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.testcontainers.containers.Container; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.emptyString; + +public class SshHostKeyVerificationStrategyTest { + private static final String COMPUTER_NAME = "MockInstanceForTest"; + + @ClassRule + public static ConnectionRule conRule = new ConnectionRule(); + + @ClassRule + public static LoggerRule loggerRule; + + @ClassRule + public static JenkinsRule jenkins = new JenkinsRule(); + + /** + * Check every defined strategy + * @throws Exception + */ + @Test + public void verifyAllStrategiesTest() throws Exception { + List<StrategyTest> strategiesToCheck = getStrategiesToTest(); + + for (StrategyTest strategyToCheck : strategiesToCheck) { + strategyToCheck.check(); + } + } + + // Return a list with all the strategies to check. Each element represents the strategy to check with the connection + // to the host and the assertion of the expected result on the console and the offline status of the instance. + private List<StrategyTest> getStrategiesToTest() throws Exception { + List<StrategyTest> strategiesToCheck = new ArrayList<>(); + + strategiesToCheck.add(forHardStrategyNotPrinted()); + strategiesToCheck.add(forHardStrategyPrinted()); + strategiesToCheck.add(forHardStrategyPrintedAndChanged()); + strategiesToCheck.add(forSoftStrategy()); + strategiesToCheck.add(forAceptNewStrategy()); + strategiesToCheck.add(forOffStrategy()); + + return strategiesToCheck; + } + + // Check the hard strategy with a key not printed + private StrategyTest forHardStrategyNotPrinted() throws Exception { + return new StrategyTest("-hardStrategyNotPrinted", new CheckNewHardStrategy()) + + .addConnectionAttempt(builder().setState(InstanceState.PENDING) + .setMessagesInLog(new String[]{ + "is not running, waiting to validate the key against the console", + "The instance console is blank. Cannot check the key"})) + + .addConnectionAttempt(builder().setMessagesInLog(new String[]{ + "has a blank console. Maybe the console is yet not available", + "The instance console is blank. Cannot check the key"})) + + .addConnectionAttempt(builder().setConsole("A console without the key") + .isOfflineByKey(true) + .setMessagesInLog(new String[]{ + "didn't print the host key. Expected a line starting with", + "presented by the instance has not been found on the instance console"} + ) + ); + } + + // Check the hard strategy with the key printed + private StrategyTest forHardStrategyPrinted() throws Exception { + return new StrategyTest("-hardStrategyPrinted", new CheckNewHardStrategy()) + .addConnectionAttempt(builder().setState(InstanceState.PENDING) + .setMessagesInLog(new String[]{ + "is not running, waiting to validate the key against the console", + "The instance console is blank. Cannot check the key"})) + + .addConnectionAttempt(builder().setMessagesInLog(new String[]{ + "has a blank console. Maybe the console is yet not available", + "The instance console is blank. Cannot check the key"})) + + .addConnectionAttempt(builder().setConsole("A text before the key\n" + conRule.ED255219_PUB_KEY + "\n a bit more text") + .setMessagesInLog(new String[]{"has been successfully checked against the instance console"}) + ); + } + + // Check the hard strategy with the key printed and the host key is changed afterward + private StrategyTest forHardStrategyPrintedAndChanged() throws Exception { + return new StrategyTest("-hardStrategyPrintedAndChanged", new CheckNewHardStrategy()) + .addConnectionAttempt(builder().setState(InstanceState.PENDING) + .setMessagesInLog(new String[]{ + "is not running, waiting to validate the key against the console", + "The instance console is blank. Cannot check the key"})) + + .addConnectionAttempt(builder().setMessagesInLog(new String[]{ + "has a blank console. Maybe the console is yet not available", + "The instance console is blank. Cannot check the key"})) + + .addConnectionAttempt(builder().setConsole("A text before the key\n" + conRule.ED255219_PUB_KEY + "\n a bit more text") + .setMessagesInLog(new String[]{ + "has been successfully checked against the instance console"})) + + .addConnectionAttempt(builder().setConsole("The console doesn't matter, the key is already stored. We check against this one") + .isOfflineByKey(true) + .isChangeHostKey(true) + .setMessagesInLog(new String[]{ + "presented by the instance has changed since first saved "}) + ); + } + + // Check the soft strategy + private StrategyTest forSoftStrategy() throws Exception { + return new StrategyTest("-softStrategy", new CheckNewSoftStrategy()) + .addConnectionAttempt(builder().setState(InstanceState.PENDING) + .setMessagesInLog(new String[]{ + "is not running, waiting to validate the key against the console", + "The instance console is blank. Cannot check the key"})) + + .addConnectionAttempt(builder().setMessagesInLog(new String[]{ + "has a blank console. Maybe the console is yet not available", + "The instance console is blank. Cannot check the key"})) + + // Allowed and persisted + .addConnectionAttempt(builder().setConsole("A console without the key") + .setMessagesInLog(new String[]{ + "didn't print the host key. Expected a line starting with", + "Cannot check the key but the connection to ", + " is allowed"})) + + // The key was stored on the previous step, gathered from known_hosts + .addConnectionAttempt(builder().setConsole("A console without the key") + .setMessagesInLog(new String[]{ + "Connection allowed after the host key has been verified"}) + ); + } + + // Check the accept-new strategy + private StrategyTest forAceptNewStrategy() throws Exception { + return new StrategyTest("-acceptNewStrategy", new AcceptNewStrategy()) + // We don't even check the console + .addConnectionAttempt(builder().setState(InstanceState.PENDING) + .setMessagesInLog(new String[]{ + "has been automatically trusted for connections"})) + + .addConnectionAttempt(builder().setMessagesInLog(new String[]{ + "Connection allowed after the host key has been verified"}) + ); + } + + // Check the off strategy + private StrategyTest forOffStrategy() throws Exception { + return new StrategyTest("-offStrategy", new NonVerifyingKeyVerificationStrategy()) + + .addConnectionAttempt(builder().setState(InstanceState.PENDING) + .setMessagesInLog(new String[]{ + "No SSH key verification"}) + ); + } + + private ConnectionAttempt.Builder builder() { + return new ConnectionAttempt.Builder(); + } + + /** + * A class to test a strategy. It stores the computer to connect to, the different configurations the computer is + * passing through and the verifier used to connect to that computer. + */ + private static class StrategyTest { + List<ConnectionAttempt> connectionAttempts = new ArrayList<>(); + MockEC2Computer computer; + ServerHostKeyVerifierImpl verifier; + + public void check() throws Exception { + for (ConnectionAttempt connectionAttempt : connectionAttempts) { + connectionAttempt.attempt(); + } + } + + private StrategyTest(String computerSuffix, SshHostKeyVerificationStrategy strategy) throws Exception { + computer = MockEC2Computer.createComputer(computerSuffix); + verifier = new ServerHostKeyVerifierImpl(computer, strategy); + } + + private StrategyTest addConnectionAttempt(ConnectionAttempt.Builder computerStateBuilder) { + // The computer and verifier are the same for every computerState of the strategy. We set them here + connectionAttempts.add(computerStateBuilder.build(computer, verifier, connectionAttempts.size() + 1)); + return this; + } + } + + /** + * A connection attempt. We establish how the computer is configured previously to the connection attempt, what + * verifier should be used to connect to it and the expected state of the computer after the attempt. + */ + private static class ConnectionAttempt { + // The console that the computer will have + private String console = null; + // The state the computer is on + private InstanceState state = InstanceState.RUNNING; + // Whether the real host key of the computer is changed before this step + private boolean changeHostKey = false; + + // The expected messages the computer has printed out on the logs + private String[] messagesInLog = new String[]{}; + // Whether the computer is set offline because a problem with the host key (it could be offline at the beginning) + private boolean isOfflineByKey = false; + + // The computer and verifier used during the try of connection + private MockEC2Computer computer; + private ServerHostKeyVerifierImpl verifier; + + // The number of this attempt (for logging purposes) + private int stage; + + /** + * Attempt a connection. It configures the computer, try to connect to it and assert its final state. + * @throws Exception + */ + private void attempt() throws Exception { + configure(); + connect(); + assertState(); + } + + private void configure() throws IOException, InterruptedException { + // Let's start again recording all the strategy classes + loggerRule = new LoggerRule(); + loggerRule.recordPackage(CheckNewHardStrategy.class, Level.INFO).capture(10); + + computer.console = console; + computer.state = state; + + if (changeHostKey) { + // Regenerate all the keys in the container + Container.ExecResult removeResult = conRule.execInContainer("sh", "-c", "rm -f /etc/ssh/ssh_host_*"); + assertThat(removeResult.getStderr(), emptyString()); + assertThat(removeResult.getStdout(), emptyString()); + Container.ExecResult regenResult = conRule.execInContainer("ssh-keygen", "-A"); + assertThat(regenResult.getStderr(), emptyString()); + } + } + + private void connect() throws Exception { + try { + // Try to connect to it + Connection con = conRule.connect(verifier); + con.close(); + } catch (IOException ignored) { + // When the connection is not verified, the connect method throws an IOException + } + } + + private void assertState() { + if (isOfflineByKey) { + assertThat(String.format("Stage %d. isOffline failed on %s using %s strategy", stage, computer.getName(), verifier.strategy.getClass().getSimpleName()), computer.isOffline(), is(true)); + assertThat(String.format("Stage %d. Offline reason failed on %s using %s strategy", stage, computer.getName(), verifier.strategy.getClass().getSimpleName()), computer.getOfflineCauseReason(), is(Messages.OfflineCause_SSHKeyCheckFailed())); + } + + for (String messageInLog : messagesInLog) { + assertThat(String.format("Stage %d. Log message not found on %s using %s strategy", stage, computer.getName(), verifier.strategy.getClass().getSimpleName()), loggerRule, LoggerRule.recorded(StringContains.containsString(messageInLog))); + } + } + + /** + * A builder to build the attempt easily + */ + static class Builder { + ConnectionAttempt connectionAttempt; + + Builder setConsole(String console) { + connectionAttempt.console = console; + return this; + } + Builder setState(InstanceState state) { + connectionAttempt.state = state; + return this; + } + Builder setMessagesInLog(String[] messagesInLog) { + connectionAttempt.messagesInLog = messagesInLog; + return this; + } + Builder isOfflineByKey(boolean isOfflineByKey) { + connectionAttempt.isOfflineByKey = isOfflineByKey; + return this; + } + Builder isChangeHostKey(boolean changeHostKey) { + connectionAttempt.changeHostKey = changeHostKey; + return this; + } + + Builder() { + connectionAttempt = new ConnectionAttempt(); + } + + private ConnectionAttempt build(MockEC2Computer computer, ServerHostKeyVerifierImpl verifier, int stage) { + connectionAttempt.stage = stage; + connectionAttempt.computer = computer; + connectionAttempt.verifier = verifier; + return connectionAttempt; + } + } + } + + // A mock ec2 computer returning the data we want + private static class MockEC2Computer extends EC2Computer { + InstanceState state = InstanceState.PENDING; + String console = null; + EC2AbstractSlave slave; + + public MockEC2Computer(EC2AbstractSlave slave) { + super(slave); + this.slave = slave; + } + + // Create a computer + private static MockEC2Computer createComputer(String suffix) throws Exception { + final EC2AbstractSlave slave = new EC2AbstractSlave(COMPUTER_NAME + suffix, "id" + suffix, "description" + suffix, "fs", 1, null, "label", null, null, "init", "tmpDir", new ArrayList<NodeProperty<?>>(), "remote", "jvm", false, "idle", null, "cloud", false, Integer.MAX_VALUE, null, ConnectionStrategy.PRIVATE_IP, -1) { + @Override + public void terminate() { + } + + @Override + public String getEc2Type() { + return null; + } + }; + + return new MockEC2Computer(slave); + } + + @Override + public String getDecodedConsoleOutput() throws AmazonClientException { + return console; + } + + @Override + public InstanceState getState() { + return state; + } + + @Override + public EC2AbstractSlave getNode() { + return slave; + } + + @Override + public SlaveTemplate getSlaveTemplate() { + return new SlaveTemplate("ami-123", EC2AbstractSlave.TEST_ZONE, null, "default", "foo", InstanceType.M1Large, false, "ttt", Node.Mode.NORMAL, "AMI description", "bar", "bbb", "aaa", "10", "fff", null, "-Xmx1g", false, "subnet-123 subnet-456", null, null, true, null, "", false, false, "", false, ""); + } + } + + // A verifier using the set strategy + private static class ServerHostKeyVerifierImpl implements ServerHostKeyVerifier { + private final EC2Computer computer; + private final SshHostKeyVerificationStrategy strategy; + + public ServerHostKeyVerifierImpl(final EC2Computer computer, final SshHostKeyVerificationStrategy strategy) { + this.computer = computer; + this.strategy = strategy; + } + + @Override + public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm, byte[] serverHostKey) throws Exception { + //TODO: change by the verifier defined on the instance template or the default one + return strategy.verify(computer, new HostKey(serverHostKeyAlgorithm, serverHostKey), null); + } + } +}
src/test/java/hudson/plugins/ec2/util/ConnectionRule.java+120 −0 added@@ -0,0 +1,120 @@ +package hudson.plugins.ec2.util; + +import com.trilead.ssh2.Connection; +import com.trilead.ssh2.ServerHostKeyVerifier; +import hudson.plugins.ec2.win.winrm.RuntimeIOException; +import org.junit.AssumptionViolatedException; +import org.junit.rules.ExternalResource; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; + +import java.io.IOException; +import java.util.logging.Logger; + +import static org.junit.Assert.assertTrue; + +/** + * Connect to a remote SSH server + * + * @author Kohsuke Kawaguchi + */ +public class ConnectionRule extends ExternalResource { + private GenericContainer sshContainer; + + public static final String USER = "jenkins"; + public static final int SSH_PORT = 22; + public static final String privateKey = "-----BEGIN RSA PRIVATE KEY-----\n" + + "MIIEowIBAAKCAQEA3x7Q+RNxkeqlDAbosRm7tXrFLuN1fcyZ4ERLEume/JLVSYny\n" + + "BM4v0KhKMkTFsyVXiMukHCS0/mYnfTvjGld76pzYdoXSzncc8zZruDnMVgzAUoSS\n" + + "P1H5wtL6ft6ZS1aHXhWte5TmkO4GDXilMwjrgwWscUhD6YhasfDiEVvaCNVnaIYt\n" + + "KMhBLY1Mb6ZMuFnqbSMWWZ9S2B49RMk/QL6XS29LeCqleTl2pd5UEFGidhiPBj6J\n" + + "WZ/dZO/cNnNvhF37L5SiqO5lLBTYcCtDgZyXsY7jfhUGl5yRbGKcTkNiRh/bkpKT\n" + + "PiAcXBAx0Hv1HDGN6g+PV/rch7dD/rD4tRORDQIDAQABAoIBAEwcYwTUUSWJeYvE\n" + + "v5PKR3H8007PYMDtDoCmS0XEU+us2v0fBWQGQeFXxxemxhn6XwXXEcBX9TXi+w2J\n" + + "ZEsUFL1Pi7fCpsqvbzy4D77kWIPyDZkYiBr5h82h0rl8jaZZegvqMSe6/3vo9j+a\n" + + "LCBgppYnVU+/aws67FVO6o8pWhMwteoemjhKkgPYRQMXDX4tNb1DiGLP8+tAridZ\n" + + "jTF8Kb2g5xbypbkO2cj7/MplXWhdkEV72xCfl2MbS2jH8Vfi+lUiBENCDaTuaJlO\n" + + "JCBSGIr39YMbxScAEcsdhtlb1dR4bYIeSUDNSTntSZq6cz3ue4NhhY3/8Zt66fBR\n" + + "CFxUqwECgYEA8RTutNAKji8wQ/tmmUqJ7Tl+R8sBFPYh/A+fnCy1GYA/V5m9hJR8\n" + + "QPbsaqTvIEZa+RffcWgk0+agBqhOeu7dGov09Tt6xZx7IbomMx+P3kU78vcPxvM2\n" + + "Sbi9DnMvDRY/RKLho2ZlHGWxK0rLcfVw+Gu2Uxt1vGpT5eQ7s/poGRECgYEA7O1Z\n" + + "xJPdgbnzlPFuXmb06IQEA9iuA07mqjDQiqE8b0DUA+btDVal0SUZgxx8NPyLDmhB\n" + + "UIGM5o9GRzHbL6xm4Y3RIUw3kEYhOyJoNr0hAXYHW2ZBemxSNW9n+IA4Bgikm/IT\n" + + "SSvjvg8fXOisAD0KCZYEfJjMZsBmSGakcGksGD0CgYEAoqtqKk0aYjhLDAQNha+7\n" + + "A3vAzraW40r1QXxVSW8NP8i+dOCC9Xuvn7I9cfQaeh+e8Ob/2SjZeLXsErHsSpz0\n" + + "Sh5XykU5IS/mEarmbaaFUAhNXDMCzU58uh/SSXbFL8JsLGbvc277GL8xXbHZNurT\n" + + "MHyViNxFhD4GoF9xPY7gQNECgYAg8tkbB100n0GKoxCwPC0u8L0GM+nvN9fIL0Wx\n" + + "Ib8f0aoqaMDqq/QfY8NqglmbnMtR05nRslJ/9cjWOc67kIQ2NdyxfsHzZG1WpfBM\n" + + "PH0MkPdw9IWCmvHL0JRq8JnZ7PXHYiDgeiQP2FaKOylAVzzAHIa/NRin6XXP98ZC\n" + + "g73IGQKBgGybl0Ir9XkPtZ41rFHN8m7CRH5Vvd9lzRnWCfhpkejkWecYtl7cz56G\n" + + "V0caNcOvjvZ790lGOxMz4yymS8OIQ6Wdjf2ds66agQn9bmwKZKhjJzGP6xoFWEOu\n" + + "nePTvdCtsGHcSCn3oNLPONIslk72Dlp21EkKIjZkBoz2nAxwO5zf\n" + + "-----END RSA PRIVATE KEY-----"; + public static final String publicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDfHtD5E3GR6qUMBuixGbu1esUu43V9zJngREsS6Z78ktVJifIEzi/QqEoyRMWzJVeIy6QcJLT+Zid9O+MaV3vqnNh2hdLOdxzzNmu4OcxWDMBShJI/UfnC0vp+3plLVodeFa17lOaQ7gYNeKUzCOuDBaxxSEPpiFqx8OIRW9oI1Wdohi0oyEEtjUxvpky4WeptIxZZn1LYHj1EyT9AvpdLb0t4KqV5OXal3lQQUaJ2GI8GPolZn91k79w2c2+EXfsvlKKo7mUsFNhwK0OBnJexjuN+FQaXnJFsYpxOQ2JGH9uSkpM+IBxcEDHQe/UcMY3qD49X+tyHt0P+sPi1E5EN user@test"; + + // The public ed-25510 host key of the server + public String ED255219_PUB_KEY; + + private Connection connection; + + public Connection connect(ServerHostKeyVerifier verifier) throws Exception { + int port = sshContainer.getMappedPort(SSH_PORT); + String ip = sshContainer.getContainerIpAddress(); + Logger log = Logger.getLogger(this.getClass().getName()); + connection = new Connection(ip, port); + connection.setTCPNoDelay(true); + + connection.connect(verifier, 0, 0); + + connection.authenticateWithPublicKey(USER, privateKey.toCharArray(),null); + assertTrue(connection.isAuthenticationComplete()); + + return connection; + } + + public void close() { + if (connection != null) { + connection.close(); + connection = null; + } + } + + public Container.ExecResult execInContainer(String... command) throws UnsupportedOperationException, IOException, InterruptedException { + return sshContainer.execInContainer(command); + } + + @Override + public void before() { + try { + sshContainer = new GenericContainer("jenkins/ssh-slave") + .withEnv("JENKINS_SLAVE_SSH_PUBKEY", publicKey) + .withExposedPorts(SSH_PORT); + + sshContainer.start(); + + } catch (RuntimeException re) { + throw new AssumptionViolatedException("The container to connect to cannot be started", re); + } + + sshContainer.start(); + try { + // We get the key after it's generated + ED255219_PUB_KEY = sshContainer.execInContainer("cat", "/etc/ssh/ssh_host_ed25519_key.pub").getStdout(); + } catch( UnsupportedOperationException | IOException | InterruptedException e) { + throw new RuntimeIOException("Cannot get the public ssh host key from the docker instance", e); + } + } + + @Override + public void after() { + sshContainer.stop(); + + if (connection!=null) + connection.close(); + + if (sshContainer.isRunning()) { + sshContainer.stop(); + } + } +} +
src/test/resources/hudson/plugins/ec2/UnixDataExport.yml+1 −0 modified@@ -14,6 +14,7 @@ connectionStrategy: PRIVATE_IP deleteRootOnTermination: false ebsOptimized: false + hostKeyVerificationStrategy: CHECK_NEW_SOFT labelString: "linux ubuntu" maxTotalUses: -1 minimumNumberOfInstances: 0
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-c89c-pvm7-33wjghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2020-2187ghsaADVISORY
- www.openwall.com/lists/oss-security/2020/05/06/3ghsamailing-listx_refsource_MLISTWEB
- github.com/jenkinsci/ec2-plugin/commit/4c9f03ae202e4730fd54eda40771fa4d3873e358ghsaWEB
- jenkins.io/security/advisory/2020-05-06/ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.