diff --git a/.gitpod/Dockerfile b/.gitpod/Dockerfile index 9f5ab9c02d91..01d953e840e7 100644 --- a/.gitpod/Dockerfile +++ b/.gitpod/Dockerfile @@ -1,6 +1,6 @@ FROM gitpod/workspace-full -ARG MAVEN_VERSION=3.9.4 +ARG MAVEN_VERSION=3.9.5 RUN brew install gh && \ bash -c ". /home/gitpod/.sdkman/bin/sdkman-init.sh && sdk install maven ${MAVEN_VERSION} && sdk default maven ${MAVEN_VERSION}" diff --git a/core/src/main/java/hudson/cli/GroovyCommand.java b/core/src/main/java/hudson/cli/GroovyCommand.java index 0d7e45e6ca18..6f9233502591 100644 --- a/core/src/main/java/hudson/cli/GroovyCommand.java +++ b/core/src/main/java/hudson/cli/GroovyCommand.java @@ -27,12 +27,14 @@ import groovy.lang.Binding; import groovy.lang.GroovyShell; import hudson.Extension; +import hudson.model.User; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.util.ArrayList; import java.util.List; import jenkins.model.Jenkins; +import jenkins.util.ScriptListener; import org.apache.commons.io.IOUtils; import org.kohsuke.args4j.Argument; import org.kohsuke.args4j.CmdLineException; @@ -63,14 +65,18 @@ protected int run() throws Exception { // this allows the caller to manipulate the JVM state, so require the execute script privilege. Jenkins.get().checkPermission(Jenkins.ADMINISTER); + final String scriptListenerCorrelationId = String.valueOf(System.identityHashCode(this)); + Binding binding = new Binding(); - binding.setProperty("out", new PrintWriter(new OutputStreamWriter(stdout, getClientCharset()), true)); + binding.setProperty("out", new ScriptListener.ListenerWriter(new PrintWriter(new OutputStreamWriter(stdout, getClientCharset()), true), GroovyCommand.class, null, scriptListenerCorrelationId, User.current())); binding.setProperty("stdin", stdin); binding.setProperty("stdout", stdout); binding.setProperty("stderr", stderr); GroovyShell groovy = new GroovyShell(Jenkins.get().getPluginManager().uberClassLoader, binding); - groovy.run(loadScript(), "RemoteClass", remaining.toArray(new String[0])); + String script = loadScript(); + ScriptListener.fireScriptExecution(script, binding, GroovyCommand.class, null, scriptListenerCorrelationId, User.current()); + groovy.run(script, "RemoteClass", remaining.toArray(new String[0])); return 0; } diff --git a/core/src/main/java/hudson/cli/GroovyshCommand.java b/core/src/main/java/hudson/cli/GroovyshCommand.java index 4f6c741c8a47..8bc4d522e254 100644 --- a/core/src/main/java/hudson/cli/GroovyshCommand.java +++ b/core/src/main/java/hudson/cli/GroovyshCommand.java @@ -28,6 +28,7 @@ import groovy.lang.Binding; import groovy.lang.Closure; import hudson.Extension; +import hudson.model.User; import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; @@ -39,6 +40,7 @@ import java.util.ArrayList; import java.util.List; import jenkins.model.Jenkins; +import jenkins.util.ScriptListener; import jline.TerminalFactory; import jline.UnsupportedTerminal; import org.codehaus.groovy.tools.shell.Groovysh; @@ -54,6 +56,9 @@ */ @Extension public class GroovyshCommand extends CLICommand { + + private final String scriptListenerCorrelationId = String.valueOf(System.identityHashCode(this)); + @Override public String getShortDescription() { return Messages.GroovyshCommand_ShortDescription(); @@ -78,6 +83,8 @@ protected int run() { commandLine.append(arg); } + // TODO Add binding + ScriptListener.fireScriptExecution(null, null, GroovyshCommand.class, null, scriptListenerCorrelationId, User.current()); Groovysh shell = createShell(stdin, stdout, stderr); return shell.run(commandLine.toString()); } @@ -96,11 +103,14 @@ protected Groovysh createShell(InputStream stdin, PrintStream stdout, } catch (InterruptedException e) { throw new RuntimeException(e); } - binding.setProperty("out", new PrintWriter(new OutputStreamWriter(stdout, charset), true)); + + binding.setProperty("out", new PrintWriter(new OutputStreamWriter(new ScriptListener.ListenerOutputStream(stdout, charset, GroovyshCommand.class, null, scriptListenerCorrelationId, User.current()), charset), true)); binding.setProperty("hudson", Jenkins.get()); // backward compatibility binding.setProperty("jenkins", Jenkins.get()); - IO io = new IO(new BufferedInputStream(stdin), stdout, stderr); + IO io = new IO(new BufferedInputStream(stdin), + new ScriptListener.ListenerOutputStream(stdout, charset, GroovyshCommand.class, null, scriptListenerCorrelationId, User.current()), + new ScriptListener.ListenerOutputStream(stderr, charset, GroovyshCommand.class, null, scriptListenerCorrelationId, User.current())); final ClassLoader cl = Jenkins.get().pluginManager.uberClassLoader; Closure registrar = new Closure(null, null) { @@ -119,9 +129,23 @@ public Object doCall(Object[] args) { return null; } }; - Groovysh shell = new Groovysh(cl, binding, io, registrar); + Groovysh shell = new LoggingGroovySh(cl, binding, io, registrar); shell.getImports().add("hudson.model.*"); return shell; } + private class LoggingGroovySh extends Groovysh { + private final Binding binding; + + LoggingGroovySh(ClassLoader cl, Binding binding, IO io, Closure registrar) { + super(cl, binding, io, registrar); + this.binding = binding; + } + + @Override + protected void maybeRecordInput(String line) { + ScriptListener.fireScriptExecution(line, binding, GroovyshCommand.class, null, scriptListenerCorrelationId, User.current()); + super.maybeRecordInput(line); + } + } } diff --git a/core/src/main/java/hudson/model/UsageStatistics.java b/core/src/main/java/hudson/model/UsageStatistics.java index cbc356aa3b77..9fc052cd1009 100644 --- a/core/src/main/java/hudson/model/UsageStatistics.java +++ b/core/src/main/java/hudson/model/UsageStatistics.java @@ -64,6 +64,7 @@ import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import jenkins.model.Jenkins; +import jenkins.security.FIPS140; import jenkins.util.SystemProperties; import net.sf.json.JSONObject; import org.kohsuke.stapler.StaplerRequest; @@ -102,8 +103,10 @@ public UsageStatistics(String keyImage) { * Returns true if it's time for us to check for new version. */ public boolean isDue() { - // user opted out. no data collection. - if (!Jenkins.get().isUsageStatisticsCollected() || DISABLED) return false; + // user opted out (explicitly or FIPS is requested). no data collection + if (!Jenkins.get().isUsageStatisticsCollected() || DISABLED || FIPS140.useCompliantAlgorithms()) { + return false; + } long now = System.currentTimeMillis(); if (now - lastAttempt > DAY) { @@ -212,7 +215,11 @@ public Permission getRequiredGlobalConfigPagePermission() { public boolean configure(StaplerRequest req, JSONObject json) throws FormException { try { // for backward compatibility reasons, this configuration is stored in Jenkins - Jenkins.get().setNoUsageStatistics(json.has("usageStatisticsCollected") ? null : true); + if (DISABLED) { + Jenkins.get().setNoUsageStatistics(Boolean.TRUE); + } else { + Jenkins.get().setNoUsageStatistics(json.has("usageStatisticsCollected") ? null : Boolean.TRUE); + } return true; } catch (IOException e) { throw new FormException(e, "usageStatisticsCollected"); diff --git a/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java b/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java index fb93f116163f..5d78c8887c8f 100644 --- a/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java +++ b/core/src/main/java/hudson/security/HudsonPrivateSecurityRealm.java @@ -50,6 +50,9 @@ import java.lang.reflect.Constructor; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -57,9 +60,12 @@ import java.util.List; import java.util.Random; import java.util.Set; +import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; @@ -70,6 +76,7 @@ import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import jenkins.model.Jenkins; +import jenkins.security.FIPS140; import jenkins.security.SecurityListener; import jenkins.security.seed.UserSeedProperty; import jenkins.util.SystemProperties; @@ -524,14 +531,23 @@ public User createAccount(String userName, String password) throws IOException { } /** - * Creates a new user account by registering a JBCrypt Hashed password with the user. + * Creates a new user account by registering a Hashed password with the user. * * @param userName The user's name - * @param hashedPassword A hashed password, must begin with {@code #jbcrypt:} + * @param hashedPassword A hashed password, must begin with {@code getPasswordHeader()} + * @see #getPasswordHeader() */ public User createAccountWithHashedPassword(String userName, String hashedPassword) throws IOException { if (!PASSWORD_ENCODER.isPasswordHashed(hashedPassword)) { - throw new IllegalArgumentException("this method should only be called with a pre-hashed password"); + final String message; + if (hashedPassword == null) { + message = "The hashed password cannot be null"; + } else if (hashedPassword.startsWith(getPasswordHeader())) { + message = "The hashed password was hashed with the correct algorithm, but the format was not correct"; + } else { + message = "The hashed password was hashed with an incorrect algorithm. Jenkins is expecting " + getPasswordHeader(); + } + throw new IllegalArgumentException(message); } User user = User.getById(userName, true); user.addProperty(Details.fromHashedPassword(hashedPassword)); @@ -885,9 +901,9 @@ public Category getCategory() { // TODO can we instead use BCryptPasswordEncoder from Spring Security, which has its own copy of BCrypt so we could drop the special library? /** - * {@link PasswordEncoder} that uses jBCrypt. + * {@link PasswordHashEncoder} that uses jBCrypt. */ - private static class JBCryptEncoder implements PasswordEncoder { + static class JBCryptEncoder implements PasswordHashEncoder { // in jBCrypt the maximum is 30, which takes ~22h with laptop late-2017 // and for 18, it's "only" 20s @SuppressFBWarnings(value = "MS_SHOULD_BE_FINAL", justification = "Accessible via System Groovy Scripts") @@ -911,6 +927,7 @@ public boolean matches(CharSequence rawPassword, String encodedPassword) { * implementation defined in jBCrypt and the Wikipedia page. * */ + @Override public boolean isHashValid(String hash) { Matcher matcher = BCRYPT_PATTERN.matcher(hash); if (matcher.matches()) { @@ -925,34 +942,131 @@ public boolean isHashValid(String hash) { } } - /* package */ static final JBCryptEncoder JBCRYPT_ENCODER = new JBCryptEncoder(); + static class PBKDF2PasswordEncoder implements PasswordHashEncoder { + + private static final String STRING_SEPARATION = ":"; + private static final int KEY_LENGTH_BITS = 512; + private static final int SALT_LENGTH_BYTES = 16; + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + // ~230ms on an Intel i7-10875H CPU (JBCryptEncoder is ~57ms) + private static final int ITTERATIONS = 210_000; + private static final String PBKDF2_ALGORITHM = "PBKDF2WithHmacSHA512"; + + private volatile SecureRandom random; // defer construction until we need to use it to not delay startup in the case of lack of entropy. + + // $PBDKF2 is already checked before we get here. + // $algorithm(HMACSHA512) : rounds : salt_in_hex $ mac_in_hex + private static final Pattern PBKDF2_PATTERN = + Pattern.compile("^\\$HMACSHA512\\:" + ITTERATIONS + "\\:[a-f0-9]{" + (SALT_LENGTH_BYTES * 2) + "}\\$[a-f0-9]{" + ((KEY_LENGTH_BITS / 8) * 2) + "}$"); + + @Override + public String encode(CharSequence rawPassword) { + try { + return generatePasswordHashWithPBKDF2(rawPassword); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new RuntimeException("Unable to generate password with PBKDF2WithHmacSHA512", e); + } + } + + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + try { + return validatePassword(rawPassword.toString(), encodedPassword); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new RuntimeException("Unable to check password with PBKDF2WithHmacSHA512", e); + } + } + + private String generatePasswordHashWithPBKDF2(CharSequence password) throws NoSuchAlgorithmException, InvalidKeySpecException { + byte[] salt = generateSalt(); + PBEKeySpec spec = new PBEKeySpec(password.toString().toCharArray(), salt, ITTERATIONS, KEY_LENGTH_BITS); + byte[] hash = generateSecretKey(spec); + return "$HMACSHA512:" + ITTERATIONS + STRING_SEPARATION + Util.toHexString(salt) + "$" + Util.toHexString(hash); + } + + private static byte[] generateSecretKey(PBEKeySpec spec) throws NoSuchAlgorithmException, InvalidKeySpecException { + SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(PBKDF2_ALGORITHM); + return secretKeyFactory.generateSecret(spec).getEncoded(); + } + + private SecureRandom secureRandom() { + // lazy initialisation so that we do not block startup due to entropy + if (random == null) { + synchronized (this) { + if (random == null) { + random = new SecureRandom(); + } + } + } + return random; + } + + private byte[] generateSalt() { + byte[] salt = new byte[SALT_LENGTH_BYTES]; + secureRandom().nextBytes(salt); + return salt; + } + + @Override + public boolean isHashValid(String hash) { + Matcher matcher = PBKDF2_PATTERN.matcher(hash); + return matcher.matches(); + } + + private static boolean validatePassword(String password, String storedPassword) throws NoSuchAlgorithmException, InvalidKeySpecException { + String[] parts = storedPassword.split("[:$]"); + int iterations = Integer.parseInt(parts[2]); + + byte[] salt = Util.fromHexString(parts[3]); + byte[] hash = Util.fromHexString(parts[4]); + + PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), + salt, iterations, hash.length * 8 /* bits in a byte */); + + byte[] generatedHashValue = generateSecretKey(spec); + + return MessageDigest.isEqual(hash, generatedHashValue); + } + + } + + /* package */ static final PasswordHashEncoder PASSWORD_HASH_ENCODER = FIPS140.useCompliantAlgorithms() ? new PBKDF2PasswordEncoder() : new JBCryptEncoder(); + + + private static final String PBKDF2 = "$PBKDF2"; + private static final String JBCRYPT = "#jbcrypt:"; + + /** + * Magic header used to detect if a password is hashed. + */ + private static String getPasswordHeader() { + return FIPS140.useCompliantAlgorithms() ? PBKDF2 : JBCRYPT; + } + // TODO check if DelegatingPasswordEncoder can be used /** - * Wraps {@link #JBCRYPT_ENCODER}. + * Wraps {@link #PASSWORD_HASH_ENCODER}. * There used to be a SHA-256-based encoder but this is long deprecated, and insecure anyway. */ /* package */ static class MultiPasswordEncoder implements PasswordEncoder { - /** - * Magic header used to detect if a password is bcrypt hashed. - */ - private static final String JBCRYPT_HEADER = "#jbcrypt:"; /* CLASSIC encoder outputs "salt:hash" where salt is [a-z]+, so we use unique prefix '#jbcyrpt" - to designate JBCRYPT-format hash. + to designate JBCRYPT-format hash and $PBKDF2 to designate PBKDF2 format hash. '#' is neither in base64 nor hex, which makes it a good choice. */ + @Override public String encode(CharSequence rawPassword) { - return JBCRYPT_HEADER + JBCRYPT_ENCODER.encode(rawPassword); + return getPasswordHeader() + PASSWORD_HASH_ENCODER.encode(rawPassword); } @Override public boolean matches(CharSequence rawPassword, String encPass) { if (isPasswordHashed(encPass)) { - return JBCRYPT_ENCODER.matches(rawPassword, encPass.substring(JBCRYPT_HEADER.length())); + return PASSWORD_HASH_ENCODER.matches(rawPassword, encPass.substring(getPasswordHeader().length())); } else { return false; } @@ -965,9 +1079,18 @@ public boolean isPasswordHashed(String password) { if (password == null) { return false; } - return password.startsWith(JBCRYPT_HEADER) && JBCRYPT_ENCODER.isHashValid(password.substring(JBCRYPT_HEADER.length())); + if (password.startsWith(getPasswordHeader())) { + return PASSWORD_HASH_ENCODER.isHashValid(password.substring(getPasswordHeader().length())); + } + if (password.startsWith(FIPS140.useCompliantAlgorithms() ? JBCRYPT : PBKDF2)) { + // switch the header to see if this is using a different encryption + LOGGER.log(Level.WARNING, "A password appears to be stored (or is attempting to be stored) that was created with a different" + + " hashing/encryption algorithm, check the FIPS-140 state of the system has not changed inadvertently"); + } else { + LOGGER.log(Level.FINE, "A password appears to be stored (or is attempting to be stored) that is not hashed/encrypted."); + } + return false; } - } public static final MultiPasswordEncoder PASSWORD_ENCODER = new MultiPasswordEncoder(); diff --git a/core/src/main/java/hudson/security/PasswordHashEncoder.java b/core/src/main/java/hudson/security/PasswordHashEncoder.java new file mode 100644 index 000000000000..14c0da93ba91 --- /dev/null +++ b/core/src/main/java/hudson/security/PasswordHashEncoder.java @@ -0,0 +1,30 @@ +/* + * The MIT License + * + * Copyright (c) 2023, 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 + * 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.security; + +import org.springframework.security.crypto.password.PasswordEncoder; + +interface PasswordHashEncoder extends PasswordEncoder { + boolean isHashValid(String hash); +} diff --git a/core/src/main/java/hudson/util/RemotingDiagnostics.java b/core/src/main/java/hudson/util/RemotingDiagnostics.java index 57b339cb9a00..61d97f91cbd7 100644 --- a/core/src/main/java/hudson/util/RemotingDiagnostics.java +++ b/core/src/main/java/hudson/util/RemotingDiagnostics.java @@ -31,6 +31,7 @@ import hudson.FilePath; import hudson.Functions; import hudson.Util; +import hudson.model.User; import hudson.remoting.AsyncFutureImpl; import hudson.remoting.DelegatingCallable; import hudson.remoting.Future; @@ -46,11 +47,13 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.TreeMap; +import java.util.UUID; import javax.management.JMException; import javax.management.MBeanServer; import javax.management.ObjectName; import jenkins.model.Jenkins; import jenkins.security.MasterToSlaveCallable; +import jenkins.util.ScriptListener; import org.codehaus.groovy.control.CompilerConfiguration; import org.codehaus.groovy.control.customizers.ImportCustomizer; import org.kohsuke.stapler.StaplerRequest; @@ -112,7 +115,12 @@ public Map call() { * Executes Groovy script remotely. */ public static String executeGroovy(String script, @NonNull VirtualChannel channel) throws IOException, InterruptedException { - return channel.call(new Script(script)); + final String correlationId = UUID.randomUUID().toString(); + final String context = channel.toString(); + ScriptListener.fireScriptExecution(script, new Binding(), RemotingDiagnostics.class, context, correlationId, User.current()); + final String output = channel.call(new Script(script)); + ScriptListener.fireScriptOutput(output, RemotingDiagnostics.class, context, correlationId, User.current()); + return output; } private static final class Script extends MasterToSlaveCallable implements DelegatingCallable { diff --git a/core/src/main/java/jenkins/util/DefaultScriptListener.java b/core/src/main/java/jenkins/util/DefaultScriptListener.java new file mode 100644 index 000000000000..3f3c966a8fd6 --- /dev/null +++ b/core/src/main/java/jenkins/util/DefaultScriptListener.java @@ -0,0 +1,67 @@ +/* + * The MIT License + * + * Copyright (c) 2022 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 jenkins.util; + +import edu.umd.cs.findbugs.annotations.NonNull; +import groovy.lang.Binding; +import hudson.Extension; +import hudson.model.User; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.codehaus.groovy.runtime.InvokerHelper; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Basic default implementation of {@link jenkins.util.ScriptListener} that just logs. + * + * @since TODO + */ +@Extension +@Restricted(NoExternalUse.class) +public class DefaultScriptListener implements ScriptListener { + public static final Logger LOGGER = Logger.getLogger(DefaultScriptListener.class.getName()); + + @Override + public void onScriptExecution(String script, Binding binding, @NonNull Object feature, Object context, @NonNull String correlationId, User user) { + String userFragment = user == null ? " (no user)" : " by user: '" + user + "'"; + LOGGER.log(Level.FINE, LOGGER.isLoggable(Level.FINEST) ? new Exception() : null, + () -> "Execution of script: '" + script + "' with binding: '" + stringifyBinding(binding) + "' in feature: '" + feature + "' and context: '" + context + "' with correlation: '" + correlationId + "'" + userFragment); + } + + @Override + public void onScriptOutput(String output, @NonNull Object feature, Object context, @NonNull String correlationId, User user) { + String userFragment = user == null ? " (no user)" : " for user: '" + user + "'"; + LOGGER.log(Level.FINER, LOGGER.isLoggable(Level.FINEST) ? new Exception() : null, + () -> "Script output: '" + output + "' in feature: '" + feature + "' and context: '" + context + "' with correlation: '" + correlationId + "'" + userFragment); + } + + private static String stringifyBinding(Binding binding) { + if (binding == null) { + return null; + } + return InvokerHelper.toString(binding.getVariables()); + } +} diff --git a/core/src/main/java/jenkins/util/ScriptListener.java b/core/src/main/java/jenkins/util/ScriptListener.java new file mode 100644 index 000000000000..60a6c77bd747 --- /dev/null +++ b/core/src/main/java/jenkins/util/ScriptListener.java @@ -0,0 +1,215 @@ +/* + * The MIT License + * + * Copyright (c) 2022 + * + * 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 jenkins.util; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import groovy.lang.Binding; +import hudson.ExtensionPoint; +import hudson.model.User; +import java.io.IOException; +import java.io.OutputStream; +import java.io.Writer; +import java.nio.charset.Charset; +import org.kohsuke.stapler.StaplerRequest; + +/** + * A listener to track in-process script use. + * + *

Note that (unsandboxed) script execution can easily result in logging configuration being changed, so if you rely + * on complete logging of scripting actions, make sure to set up logging to remote systems.

+ * + * @see jenkins.model.Jenkins#_doScript(StaplerRequest, org.kohsuke.stapler.StaplerResponse, javax.servlet.RequestDispatcher, hudson.remoting.VirtualChannel, hudson.security.ACL) + * @see hudson.cli.GroovyCommand + * @see hudson.cli.GroovyshCommand + * @see jenkins.util.groovy.GroovyHookScript + * + * @since TODO + */ +public interface ScriptListener extends ExtensionPoint { + + /** + * Called just before scripts are executed. + * + * Examples include: + *
    + *
  • Groovy script console script execution
  • + *
  • {@code groovy} CLI command
  • + *
  • Start and end of a {@code groovysh} CLI command session, as well as individual commands submitted
  • + *
  • Execution of scripts integrating with Script Security Plugin
  • + *
+ * + * @param script The script to be executed or {@code null} if no script is available yet (e.g. a shell has just been opened). + * @param binding The script binding, or {@code null} if unavailable/inapplicable. + * @param feature The feature that triggered this event. Usually a fixed string or even a {@link java.lang.Class} + * if that's unambiguously describing the feature (e.g., {@link hudson.cli.GroovyshCommand#getClass()}). + * @param context Object representing the script definition context (e.g. {@link hudson.model.Run}). + * Can be {@code null} if not applicable (e.g., CLI commands not acting on jobs/builds). + * @param correlationId This value is used to correlate this script event to other, related script events. + * Callers are expected to provide values that allow receivers to associate script execution + * and output. Related events should have identical values. + * @param user If available, the user who executed the script. Can be {@code null}. + */ + default void onScriptExecution(@CheckForNull String script, @CheckForNull Binding binding, @NonNull Object feature, @CheckForNull Object context, @NonNull String correlationId, @CheckForNull User user) { + } + + /** + * Called when a script produces output. This can include error output. + * + * @param output The output of the script. + * @param feature The feature that triggered this event. Usually a fixed string or even a {@link java.lang.Class} + * if that's unambiguously describing the feature (e.g., {@link hudson.cli.GroovyshCommand#getClass()}). + * @param context Object representing the script definition context (e.g. {@link hudson.model.Run}). + * Can be {@code null} if not applicable (e.g., CLI commands not acting on jobs/builds). + * @param correlationId This value is used to correlate this script event to other, related script events. + * Callers are expected to provide values that allow receivers to associate script execution + * and output. Related events should have identical values. + * @param user If available, the user for which the output was created. Can be {@code null}. + */ + default void onScriptOutput(@CheckForNull String output, @NonNull Object feature, @CheckForNull Object context, @NonNull String correlationId, @CheckForNull User user) { + } + + /** + * Fires the {@link #onScriptExecution(String, Binding, Object, Object, String, hudson.model.User)} event. + * + * @param script The script to be executed or {@code null} if no script is available yet (e.g. a shell has just been opened). + * @param binding The script binding, or {@code null} if unavailable/inapplicable. + * @param feature The feature that triggered this event. Usually a fixed string or even a {@link java.lang.Class} + * if that's unambiguously describing the feature (e.g., {@link hudson.cli.GroovyshCommand#getClass()}). + * @param context Object representing the script definition context (e.g. {@link hudson.model.Run}). + * Can be {@code null} if not applicable (e.g., CLI commands not acting on jobs/builds). + * @param correlationId This value is used to correlate this script event to other, related script events. + * Callers are expected to provide values that allow receivers to associate script execution + * and output. Related events should have identical values. + * @param user If available, the user who caused this event. Can be {@code null}. + */ + // TODO Should null script be allowed? Do we care about e.g. someone starting groovysh but not actually executing a command (yet)? + static void fireScriptExecution(@CheckForNull String script, @CheckForNull Binding binding, @NonNull Object feature, @CheckForNull Object context, @NonNull String correlationId, @CheckForNull User user) { + Listeners.notify(ScriptListener.class, true, listener -> listener.onScriptExecution(script, binding, feature, context, correlationId, user)); + } + + /** + * Fires the {@link #onScriptOutput(String, Object, Object, String, hudson.model.User)} event. + * + * @param output The output of the script. + * @param context Object representing the script definition context (e.g. {@link hudson.model.Run}). + * Can be {@code null} if not applicable (e.g., CLI commands not acting on jobs/builds). + * @param correlationId This value is used to correlate this script event to other, related script events. + * Callers are expected to provide values that allow receivers to associate script execution + * and output. Related events should have identical values. + * @param user If available, the user for which the output was created. Can be {@code null}. + */ + static void fireScriptOutput(@CheckForNull String output, @NonNull Object feature, @CheckForNull Object context, @NonNull String correlationId, @CheckForNull User user) { + Listeners.notify(ScriptListener.class, true, listener -> listener.onScriptOutput(output, feature, context, correlationId, user)); + } + + /** + * {@link java.io.Writer} that calls {@link #fireScriptOutput(String, Object, Object, String, hudson.model.User)} with the + * output it writes to the wrapped {@link java.io.Writer}, and otherwise just forwards {@link #flush()} and {@link #close()}. + */ + class ListenerWriter extends Writer { + + private final Writer writer; + private final Object feature; + private final Object context; + private final String correlationId; + private final User user; + + @SuppressFBWarnings("EI_EXPOSE_REP2") + public ListenerWriter(Writer writer, Object feature, Object context, String correlationId, User user) { + this.writer = writer; + this.feature = feature; + this.context = context; + this.correlationId = correlationId; + this.user = user; + } + + @Override + public void write(@NonNull char[] cbuf, int off, int len) throws IOException { + ScriptListener.fireScriptOutput(String.copyValueOf(cbuf, off, len), feature, context, correlationId, user); + writer.write(cbuf, off, len); + } + + @Override + public void flush() throws IOException { + writer.flush(); + } + + @Override + public void close() throws IOException { + writer.close(); + } + } + + /** + * {@link java.io.OutputStream} that calls{@link #fireScriptOutput(String, Object, Object, String, hudson.model.User)} with + * the output it writes to the wrapped {@link java.io.OutputStream}, and otherwise just forwards {@link #flush()} + * and {@link #close()}. + */ + class ListenerOutputStream extends OutputStream { + + private final OutputStream os; + private final Charset charset; + private final Object feature; + private final Object context; + private final String correlationId; + private final User user; + + @SuppressFBWarnings("EI_EXPOSE_REP2") + public ListenerOutputStream(OutputStream os, Charset charset, Object feature, Object context, String correlationId, User user) { + this.os = os; + this.charset = charset; + this.feature = feature; + this.context = context; + this.correlationId = correlationId; + this.user = user; + } + + @Override + public void write(int b) throws IOException { + // Let's hope for verbosity's sake that nobody calls this directly, #write(byte[], int, int) should take care of regular calls. + ScriptListener.fireScriptOutput(new String(new byte[] { (byte) b }, charset), feature, context, correlationId, user); + os.write(b); + } + + @Override + public void write(@NonNull byte[] b, int off, int len) throws IOException { + final String writtenString = new String(b, charset).substring(off, len - off); + ScriptListener.fireScriptOutput(writtenString, feature, context, correlationId, user); + os.write(b, off, len); + } + + @Override + public void flush() throws IOException { + os.flush(); + } + + @Override + public void close() throws IOException { + os.close(); + } + } +} diff --git a/core/src/main/java/jenkins/util/groovy/GroovyHookScript.java b/core/src/main/java/jenkins/util/groovy/GroovyHookScript.java index 61b13e1d090d..eeb816f31ffd 100644 --- a/core/src/main/java/jenkins/util/groovy/GroovyHookScript.java +++ b/core/src/main/java/jenkins/util/groovy/GroovyHookScript.java @@ -7,6 +7,7 @@ import groovy.lang.Binding; import groovy.lang.GroovyCodeSource; import groovy.lang.GroovyShell; +import hudson.model.User; import java.io.File; import java.io.IOException; import java.net.URL; @@ -16,6 +17,7 @@ import java.util.logging.Logger; import javax.servlet.ServletContext; import jenkins.model.Jenkins; +import jenkins.util.ScriptListener; import jenkins.util.SystemProperties; /** @@ -133,6 +135,7 @@ protected void execute(File f) { @SuppressFBWarnings(value = "GROOVY_SHELL", justification = "Groovy hook scripts are a feature, not a bug") protected void execute(GroovyCodeSource s) { try { + ScriptListener.fireScriptExecution(s.getScriptText(), bindings, this.getClass(), s.getFile(), this.getClass().getName() + ":" + hook, User.current()); createShell().evaluate(s); } catch (RuntimeException x) { LOGGER.log(WARNING, "Failed to run script " + s.getName(), x); diff --git a/core/src/main/resources/hudson/model/UsageStatistics/global.groovy b/core/src/main/resources/hudson/model/UsageStatistics/global.groovy index 458880889eb1..901f0ead5448 100644 --- a/core/src/main/resources/hudson/model/UsageStatistics/global.groovy +++ b/core/src/main/resources/hudson/model/UsageStatistics/global.groovy @@ -1,7 +1,19 @@ package hudson.model.UsageStatistics +import hudson.model.UsageStatistics +import jenkins.security.FIPS140 +import hudson.Functions + def f=namespace(lib.FormTagLib) f.section(title: _("Usage Statistics")) { - f.optionalBlock(field: "usageStatisticsCollected", checked: app.usageStatisticsCollected, title: _("statsBlurb")) + if (UsageStatistics.DISABLED) { + span(class: "jenkins-not-applicable") { + raw(_("disabledBySystemProperty")) + } + } else if (FIPS140.useCompliantAlgorithms()) { + f.optionalBlock(field: "usageStatisticsCollected", checked: app.usageStatisticsCollected, title: _("statsBlurbFIPS")) + } else { + f.optionalBlock(field: "usageStatisticsCollected", checked: app.usageStatisticsCollected, title: _("statsBlurb")) + } } diff --git a/core/src/main/resources/hudson/model/UsageStatistics/global.properties b/core/src/main/resources/hudson/model/UsageStatistics/global.properties index 01971f396652..db939f2a7515 100644 --- a/core/src/main/resources/hudson/model/UsageStatistics/global.properties +++ b/core/src/main/resources/hudson/model/UsageStatistics/global.properties @@ -22,4 +22,8 @@ statsBlurb=\ Help make Jenkins better by sending anonymous usage statistics and crash reports to the Jenkins project +statsBlurbFIPS=\ + Help make Jenkins better by sending telemetry to the Jenkins project +disabledBySystemProperty=\ + The option to send anonymous usage statistics, crash reports and telemetry to the Jenkins project is disabled by a System Property. diff --git a/core/src/main/resources/hudson/model/UsageStatistics/help-usageStatisticsCollected.jelly b/core/src/main/resources/hudson/model/UsageStatistics/help-usageStatisticsCollected.jelly index 9312e653fee6..80237768112a 100644 --- a/core/src/main/resources/hudson/model/UsageStatistics/help-usageStatisticsCollected.jelly +++ b/core/src/main/resources/hudson/model/UsageStatistics/help-usageStatisticsCollected.jelly @@ -6,25 +6,35 @@ When enabled, Jenkins periodically sends information to the Jenkins project. The Jenkins project uses this information to set development priorities. -

General usage statistics

-
-

Jenkins reports the following general usage statistics:

-
    -
  • Your Jenkins version
  • -
  • For your controller and each agent, the OS type and number of executors
  • -
  • Installed plugins and their versions
  • -
  • Number of items (like jobs) of each item type
  • -
-

- This does not report any personally identifiable information. The only information reported by Jenkins is information inherently revealed by the HTTP protocol, such as the IP address. - - These usage statistics are aggregated, updated monthly, and published to stats.jenkins.io -

-
+ + +

General usage statistics

+
+

Jenkins reports the following general usage statistics:

+
    +
  • Your Jenkins version
  • +
  • For your controller and each agent, the OS type and number of executors
  • +
  • Installed plugins and their versions
  • +
  • Number of items (like jobs) of each item type
  • +
+

+ This does not report any personally identifiable information. + The only information reported by Jenkins is information inherently revealed by the HTTP protocol, such as the IP address. + These usage statistics are aggregated, updated monthly, and published to stats.jenkins.io +

+
+

Telemetry collection

- In addition to the general usage statistics listed above, the Jenkins project collects telemetry data from specific trials to inform future development. + + + The Jenkins project collects telemetry data from specific trials to inform future development. + + + In addition to the general usage statistics listed above, the Jenkins project collects telemetry data from specific trials to inform future development. + + Each trial has a specific purpose and a defined end date, after which collection stops, independent of the installed versions of Jenkins or plugins. Once a trial is complete, the trial results may be aggregated and shared with the developer community.

diff --git a/core/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java b/core/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java new file mode 100644 index 000000000000..9d7e2b7b5980 --- /dev/null +++ b/core/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java @@ -0,0 +1,146 @@ +package hudson.security; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import hudson.security.HudsonPrivateSecurityRealm.JBCryptEncoder; +import hudson.security.HudsonPrivateSecurityRealm.PBKDF2PasswordEncoder; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.time.Duration; +import javax.crypto.SecretKeyFactory; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +public class HudsonPrivateSecurityRealmTest { + + // MySecurePassword + private static final String PBKDF2_HMAC_SHA512_ENCODED_PASSWORD = + "$HMACSHA512:210000:30f9e0a5470a8bc67f128ca1aae25dd4$88abaca4f442caeff0096ec0f75df2d77cc31a956c564133232f4d2532a72c8d4380a718d5b2a3dccab9e752027eeadd8f9f2c0c624505531bf3a57ec7d08aad"; + + /* + * This exists so that we can easily check the complexity of how long this takes (ie is the number of iterations we + * use correct for the state of CPUs). + * We do not want to assert that the range < x and > y as that would make the test flaky on overloaded + * or slow hardware, so this is commented out but left for ease of running locally when desired. + */ + //@Test + public void timingPBKDF2() { + // ignore the salt generation - check just matching.... + PBKDF2PasswordEncoder encoder = new PBKDF2PasswordEncoder(); + String encoded = encoder.encode("thisIsMyPassword1"); + + long start = System.nanoTime(); + for (int i = 0; i < 10; i++) { + System.out.println(encoder.matches("thisIsMyPassword" + i, encoded)); + } + long end = System.nanoTime(); + long duration = end - start; + long duration_per_iteration = duration / 10; + + Duration d = Duration.ofNanos(duration_per_iteration); + System.out.println("PBKDF2 took " + d.toNanos() + "ns"); + System.out.println("PBKDF2 took " + d.toMillis() + "ms"); + System.out.println("PBKDF2 took " + d.toSeconds() + "s"); + } + + /* + * This exists so that we can easily check the complexity of how long this takes (ie is the number of iterations we + * use correct for the state of CPUs). + * We do not want to assert that the range < x and > y as that would make the test flaky on overloaded + * or slow hardware, so this is commented out but left for ease of running locally when desired. + */ + //@Test + public void timingBcrypt() { + // ignore the salt generation - check just matching.... + JBCryptEncoder encoder = new JBCryptEncoder(); + String encoded = encoder.encode("thisIsMyPassword1"); + + long start = System.nanoTime(); + for (int i = 0; i < 10; i++) { + System.out.println(encoder.matches("thisIsMyPassword" + i, encoded)); + } + long end = System.nanoTime(); + long duration = end - start; + long duration_per_iteration = duration / 10; + + Duration d = Duration.ofNanos(duration_per_iteration); + System.out.println("BCrypt took " + d.toNanos() + "ns"); + System.out.println("BCrypt took " + d.toMillis() + "ms"); + System.out.println("BCrypt took " + d.toSeconds() + "s"); + } + + @Test + public void testPBKDF2RegExp() { + PBKDF2PasswordEncoder encoder = new PBKDF2PasswordEncoder(); + String encoded = encoder.encode("thisIsMyPassword"); + assertTrue(encoder.isHashValid(encoded)); + + // and a static one for other tests... + assertTrue(encoder.isHashValid(PBKDF2_HMAC_SHA512_ENCODED_PASSWORD)); + + assertFalse(encoder.isHashValid( + "$HMACSHA512:1000:fe899fcfcef4302ec3f0d36164efefdc$0781364eae9dac4ef1c4c3bf34c28e13965b46105fec0b6fcf4bae78e246fb5e51a1694fff19acac2dfb37b16055092644f3682c25beea9a7a286bf94e52f63b"), + "not enough iterations"); + assertFalse(encoder.isHashValid( + "$HMACSHA512:500000:fe899fcfcef4302ec3f0d36164efefdc$0781364eae9dac4ef1c4c3bf34c28e13965b46105fec0b6fcf4bae78e246fb5e51a1694fff19acac2dfb37b16055092644f3682c25beea9a7a286bf94e52f63b"), + "too many iterations"); + assertFalse(encoder.isHashValid( + "$HMACSHA512:210000:fe899fcfcef4302ec3f0d36164efef$0781364eae9dac4ef1c4c3bf34c28e13965b46105fec0b6fcf4bae78e246fb5e51a1694fff19acac2dfb37b16055092644f3682c25beea9a7a286bf94e52f63b"), + "salt too short"); + assertFalse(encoder.isHashValid( + "$HMACSHA512:210000:f6865c02cc759fd061db0f3121a093e094$0781364eae9dac4ef1c4c3bf34c28e13965b46105fec0b6fcf4bae78e246fb5e51a1694fff19acac2dfb37b16055092644f3682c25beea9a7a286bf94e52f63b"), + "salt too long"); + assertFalse(encoder.isHashValid( + "$HMACSHA512:210000:f6865c02cc759fd061db0f3121a093e094$079bd3a0c2851248343584a9a4625360e9ebb13c36be49542268d2ebdbd1fb71f004db9ce7335a61885985e32e08cb20215f"), + "hash result too short"); + assertFalse(encoder.isHashValid( + "$HMACSHA512:210000:f6865c02cc759fd061db0f3121a093e094$0781364eae9dac4ef1c4c3bf34c28e13965b46105fec0b6fcf4bae78e246fb5e51a1694fff19acac2dfb37b16055092644f3682c25beea9a7a286bf94e52f63b42662"), + "hash result too long"); + assertFalse(encoder.isHashValid( + "$HMACSHA256:210000:f6865c02cc759fd061db0f3121a093e094$0781364eae9dac4ef1c4c3bf34c28e13965b46105fec0b6fcf4bae78e246fb5e51a1694fff19acac2dfb37b16055092644f3682c25beea9a7a286bf94e52f63b42662"), + "wrong format"); + assertFalse(encoder.isHashValid( + "::$sfdfssdf"), + "wrong format"); + + } + + @Test + public void testPBKDF2PasswordMatching() { + PBKDF2PasswordEncoder encoder = new PBKDF2PasswordEncoder(); + String encoded = encoder.encode("thisIsMyPassword"); + assertTrue(encoder.matches("thisIsMyPassword", encoded)); + assertFalse(encoder.matches("thisIsNotMyPassword", encoded)); + } + + @Test + public void passwordPBKDF2WithMissingAgorithm() throws Exception { + HudsonPrivateSecurityRealm.PBKDF2PasswordEncoder pbkdf2PasswordEncoder = new HudsonPrivateSecurityRealm.PBKDF2PasswordEncoder(); + try (var ignored = mockStatic(SecretKeyFactory.class)) { + when(SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512")).thenThrow(NoSuchAlgorithmException.class); + assertThrows(RuntimeException.class, () -> pbkdf2PasswordEncoder.encode("password")); + + assertTrue(pbkdf2PasswordEncoder.isHashValid(PBKDF2_HMAC_SHA512_ENCODED_PASSWORD)); + assertThrows(RuntimeException.class, () -> pbkdf2PasswordEncoder.matches("MySecurePassword", PBKDF2_HMAC_SHA512_ENCODED_PASSWORD)); + } + } + + @Test + public void passwordPBKDF2HashWithInvalidKeySpec() throws Exception { + HudsonPrivateSecurityRealm.PBKDF2PasswordEncoder pbkdf2PasswordEncoder = new HudsonPrivateSecurityRealm.PBKDF2PasswordEncoder(); + try (var ignored = mockStatic(SecretKeyFactory.class)) { + SecretKeyFactory skf = mock(SecretKeyFactory.class); + when(SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512")).thenReturn(skf); + when(skf.generateSecret(Mockito.any())).thenThrow(InvalidKeySpecException.class); + assertThrows(RuntimeException.class, () -> pbkdf2PasswordEncoder.encode("password")); + + assertTrue(pbkdf2PasswordEncoder.isHashValid(PBKDF2_HMAC_SHA512_ENCODED_PASSWORD)); + assertThrows(RuntimeException.class, () -> pbkdf2PasswordEncoder.matches("MySecurePassword", PBKDF2_HMAC_SHA512_ENCODED_PASSWORD)); + } + } +} diff --git a/test/src/test/java/hudson/PluginManagerSecurity3072Test.java b/test/src/test/java/hudson/PluginManagerSecurity3072Test.java deleted file mode 100644 index 774d6b5ce1c6..000000000000 --- a/test/src/test/java/hudson/PluginManagerSecurity3072Test.java +++ /dev/null @@ -1,107 +0,0 @@ -package hudson; - -import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE; -import static java.nio.file.attribute.PosixFilePermission.OWNER_READ; -import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE; -import static org.awaitility.Awaitility.await; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeFalse; - -import hudson.model.RootAction; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.LinkOption; -import java.nio.file.attribute.PosixFilePermission; -import java.util.Arrays; -import java.util.Comparator; -import java.util.EnumSet; -import java.util.HashSet; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import javax.servlet.ServletException; -import jenkins.model.Jenkins; -import org.htmlunit.html.HtmlForm; -import org.htmlunit.html.HtmlPage; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; -import org.jvnet.hudson.test.Issue; -import org.jvnet.hudson.test.JenkinsRule; -import org.jvnet.hudson.test.TestExtension; -import org.kohsuke.stapler.StaplerRequest; -import org.kohsuke.stapler.StaplerResponse; - -public class PluginManagerSecurity3072Test { - - @Rule - public JenkinsRule r = PluginManagerUtil.newJenkinsRule(); - - @Rule - public TemporaryFolder tmp = new TemporaryFolder(); - - @Test - @Issue("SECURITY-3072") - public void verifyUploadedPluginFromURLPermission() throws Exception { - assumeFalse(Functions.isWindows()); - - HtmlPage page = r.createWebClient().goTo("pluginManager/advanced"); - HtmlForm f = page.getFormByName("uploadPlugin"); - f.getInputByName("pluginUrl").setValue(Jenkins.get().getRootUrl() + "pluginManagerGetPlugin/htmlpublisher.jpi"); - r.submit(f); - - File filesRef = Files.createTempFile("tmp", ".tmp").toFile(); - File filesTmpDir = filesRef.getParentFile(); - filesRef.deleteOnExit(); - - final Set[] filesPermission = new Set[]{new HashSet<>()}; - await().pollInterval(250, TimeUnit.MILLISECONDS) - .atMost(10, TimeUnit.SECONDS) - .until(() -> { - Optional lastUploadedPluginDir = Arrays.stream(Objects.requireNonNull( - filesTmpDir.listFiles((file, fileName) -> - fileName.startsWith("uploadDir")))). - max(Comparator.comparingLong(File::lastModified)); - if (lastUploadedPluginDir.isPresent()) { - filesPermission[0] = Files.getPosixFilePermissions(lastUploadedPluginDir.get().toPath(), LinkOption.NOFOLLOW_LINKS); - Optional pluginFile = Arrays.stream(Objects.requireNonNull( - lastUploadedPluginDir.get().listFiles((file, fileName) -> - fileName.startsWith("uploaded")))). - max(Comparator.comparingLong(File::lastModified)); - assertTrue(pluginFile.isPresent()); - return true; - } else { - return false; - } - }); - assertEquals(EnumSet.of(OWNER_EXECUTE, OWNER_READ, OWNER_WRITE), filesPermission[0]); - } - - @TestExtension("verifyUploadedPluginFromURLPermission") - public static final class ReturnPluginJpiAction implements RootAction { - - @Override - public String getIconFileName() { - return "gear2.png"; - } - - @Override - public String getDisplayName() { - return "URL to retrieve a plugin jpi"; - } - - @Override - public String getUrlName() { - return "pluginManagerGetPlugin"; - } - - public void doDynamic(StaplerRequest staplerRequest, StaplerResponse staplerResponse) throws ServletException, IOException { - staplerResponse.setContentType("application/octet-stream"); - staplerResponse.setStatus(200); - staplerResponse.serveFile(staplerRequest, PluginManagerTest.class.getClassLoader().getResource("plugins/htmlpublisher.jpi")); - } - } -} diff --git a/test/src/test/java/hudson/PluginManagerTest.java b/test/src/test/java/hudson/PluginManagerTest.java index 507354b8e1b8..dddd9239d97a 100644 --- a/test/src/test/java/hudson/PluginManagerTest.java +++ b/test/src/test/java/hudson/PluginManagerTest.java @@ -804,6 +804,43 @@ public void noInjectionOnAvailablePluginsPage() throws Exception { } } + @Test + @Issue("SECURITY-3072") + public void verifyUploadedPluginFromURLPermission() throws Exception { + assumeFalse(Functions.isWindows()); + + HtmlPage page = r.createWebClient().goTo("pluginManager/advanced"); + HtmlForm f = page.getFormByName("uploadPlugin"); + f.getInputByName("pluginUrl").setValue(Jenkins.get().getRootUrl() + "pluginManagerGetPlugin/htmlpublisher.jpi"); + r.submit(f); + + File filesRef = Files.createTempFile("tmp", ".tmp").toFile(); + File filesTmpDir = filesRef.getParentFile(); + filesRef.deleteOnExit(); + + final Set[] filesPermission = new Set[]{new HashSet<>()}; + await().pollInterval(250, TimeUnit.MILLISECONDS) + .atMost(10, TimeUnit.SECONDS) + .until(() -> { + Optional lastUploadedPluginDir = Arrays.stream(Objects.requireNonNull( + filesTmpDir.listFiles((file, fileName) -> + fileName.startsWith("uploadDir")))). + max(Comparator.comparingLong(File::lastModified)); + if (lastUploadedPluginDir.isPresent()) { + filesPermission[0] = Files.getPosixFilePermissions(lastUploadedPluginDir.get().toPath(), LinkOption.NOFOLLOW_LINKS); + Optional pluginFile = Arrays.stream(Objects.requireNonNull( + lastUploadedPluginDir.get().listFiles((file, fileName) -> + fileName.startsWith("uploaded")))). + max(Comparator.comparingLong(File::lastModified)); + assertTrue(pluginFile.isPresent()); + return true; + } else { + return false; + } + }); + assertEquals(EnumSet.of(OWNER_EXECUTE, OWNER_READ, OWNER_WRITE), filesPermission[0]); + } + static class AlertHandlerImpl implements AlertHandler { List messages = Collections.synchronizedList(new ArrayList<>()); @@ -838,4 +875,29 @@ public void doDynamic(StaplerRequest staplerRequest, StaplerResponse staplerResp } } + @TestExtension("verifyUploadedPluginFromURLPermission") + public static final class Security3072JpiAction implements RootAction { + + @Override + public String getIconFileName() { + return "gear2.png"; + } + + @Override + public String getDisplayName() { + return "URL to retrieve a plugin jpi"; + } + + @Override + public String getUrlName() { + return "pluginManagerGetPlugin"; + } + + public void doDynamic(StaplerRequest staplerRequest, StaplerResponse staplerResponse) throws ServletException, IOException { + staplerResponse.setContentType("application/octet-stream"); + staplerResponse.setStatus(200); + staplerResponse.serveFile(staplerRequest, PluginManagerTest.class.getClassLoader().getResource("plugins/htmlpublisher.jpi")); + } + } + } diff --git a/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmFIPSTest.java b/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmFIPSTest.java new file mode 100644 index 000000000000..155e9cab1adc --- /dev/null +++ b/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmFIPSTest.java @@ -0,0 +1,133 @@ +/* + * The MIT License + * + * Copyright (c) 2015, CloudBees, Inc. and others + * + * 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.security; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.assertThrows; + +import hudson.model.User; +import hudson.security.HudsonPrivateSecurityRealm.Details; +import java.util.logging.Level; +import org.hamcrest.Matcher; +import org.htmlunit.FailingHttpStatusCodeException; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; +import org.jvnet.hudson.test.FlagRule; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.JenkinsRule.WebClient; +import org.jvnet.hudson.test.LoggerRule; + + +@For(HudsonPrivateSecurityRealm.class) +public class HudsonPrivateSecurityRealmFIPSTest { + + // the jbcrypt encoded for of "a" without the quotes + private static final String JBCRYPT_ENCODED_PASSWORD = "#jbcrypt:$2a$06$m0CrhHm10qJ3lXRY.5zDGO3rS2KdeeWLuGmsfGlMfOxih58VYVfxe"; + + @ClassRule + // do not use the FIPS140 class here as that initializes the field before we set the property! + public static TestRule flagRule = FlagRule.systemProperty("jenkins.security.FIPS140.COMPLIANCE", "true"); + + @Rule + public LoggerRule lr = new LoggerRule().record(HudsonPrivateSecurityRealm.class, Level.WARNING).capture(5); + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + public void generalLogin() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + j.jenkins.setSecurityRealm(securityRealm); + + User u1 = securityRealm.createAccount("user", "password"); + u1.setFullName("A User"); + u1.save(); + + // we should be using PBKDF2 hasher + String hashedPassword = u1.getProperty(Details.class).getPassword(); + assertThat(hashedPassword, startsWith("$PBKDF2$HMACSHA512:210000:")); + + WebClient wc = j.createWebClient(); + wc.login("user", "password"); + + assertThrows(FailingHttpStatusCodeException.class, () -> wc.login("user", "wrongPass")); + } + + @Test + public void userCreationWithHashedPasswords() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + j.jenkins.setSecurityRealm(securityRealm); + // "password" after it has gone through the KDF + securityRealm.createAccountWithHashedPassword("user_hashed", + "$PBKDF2$HMACSHA512:210000:ffbb207b847010af98cdd2b09c79392c$f67c3b985daf60db83a9088bc2439f7b77016d26c1439a9877c4f863c377272283ce346edda4578a5607ea620a4beb662d853b800f373297e6f596af797743a6"); + WebClient wc = j.createWebClient(); + + // login should succeed + wc.login("user_hashed", "password"); + + assertThrows(FailingHttpStatusCodeException.class, () -> wc.login("user_hashed", "password2")); + assertThat(lr, not(hasIncorrectHashingLogEntry())); + } + + @Test + public void userLoginAfterEnablingFIPS() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + j.jenkins.setSecurityRealm(securityRealm); + + User u1 = securityRealm.createAccount("user", "a"); + u1.setFullName("A User"); + // overwrite the password property using an password created using an incorrect algorithm + u1.addProperty(Details.fromHashedPassword(JBCRYPT_ENCODED_PASSWORD)); + + u1.save(); + assertThat(u1.getProperty(Details.class).getPassword(), is(JBCRYPT_ENCODED_PASSWORD)); + + try (WebClient wc = j.createWebClient()) { + assertThrows(FailingHttpStatusCodeException.class, () -> wc.login("user", "a")); + } + assertThat(lr, hasIncorrectHashingLogEntry()); + } + + @Test + public void userCreationWithJBCryptPasswords() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + + IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, + () -> securityRealm.createAccountWithHashedPassword("user_hashed_incorrect_algorithm", JBCRYPT_ENCODED_PASSWORD)); + assertThat(illegalArgumentException.getMessage(), + is("The hashed password was hashed with an incorrect algorithm. Jenkins is expecting $PBKDF2")); + } + + private static Matcher hasIncorrectHashingLogEntry() { + return LoggerRule.recorded(is( + "A password appears to be stored (or is attempting to be stored) that was created with a different hashing/encryption algorithm, check the FIPS-140 state of the system has not changed inadvertently")); + } +} diff --git a/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java b/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java index 25d1dd18cd01..4363a92e4468 100644 --- a/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java +++ b/test/src/test/java/hudson/security/HudsonPrivateSecurityRealmTest.java @@ -41,6 +41,7 @@ import edu.umd.cs.findbugs.annotations.NonNull; import hudson.ExtensionList; import hudson.model.User; +import hudson.security.HudsonPrivateSecurityRealm.Details; import hudson.security.pages.SignupPage; import java.lang.reflect.Field; import java.net.URI; @@ -51,11 +52,14 @@ import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.logging.Level; import jenkins.security.ApiTokenProperty; import jenkins.security.SecurityListener; import jenkins.security.apitoken.ApiTokenPropertyConfiguration; import jenkins.security.seed.UserSeedProperty; import org.apache.commons.lang.StringUtils; +import org.hamcrest.Matcher; +import org.htmlunit.FailingHttpStatusCodeException; import org.htmlunit.HttpMethod; import org.htmlunit.WebRequest; import org.htmlunit.html.HtmlForm; @@ -71,15 +75,23 @@ import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.JenkinsRule.WebClient; +import org.jvnet.hudson.test.LoggerRule; import org.jvnet.hudson.test.TestExtension; import org.mindrot.jbcrypt.BCrypt; @For({UserSeedProperty.class, HudsonPrivateSecurityRealm.class}) public class HudsonPrivateSecurityRealmTest { + // the PBKDF encoded form of "password" without the quotes + private static final String PBKDF_ENDOCED_PASSWORD = + "$PBKDF2$HMACSHA512:210000:ffbb207b847010af98cdd2b09c79392c$f67c3b985daf60db83a9088bc2439f7b77016d26c1439a9877c4f863c377272283ce346edda4578a5607ea620a4beb662d853b800f373297e6f596af797743a6"; + @Rule public JenkinsRule j = new JenkinsRule(); + @Rule + public LoggerRule lr = new LoggerRule().record(HudsonPrivateSecurityRealm.class, Level.WARNING).capture(5); + private SpySecurityListenerImpl spySecurityListener; @Before @@ -512,6 +524,7 @@ public void createAccountSupportsHashedPasswords() throws Exception { XmlPage w2 = (XmlPage) wc.goTo("whoAmI/api/xml", "application/xml"); assertThat(w2, hasXPath("//name", is("user_hashed"))); + assertThat(lr, not(hasIncorrectHashingLogEntry())); } @Test @@ -718,6 +731,40 @@ public void changingPassword_withSeedDisable_hasNoImpact() throws Exception { } } + @Test + public void userLoginAfterDisablingFIPS() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + j.jenkins.setSecurityRealm(securityRealm); + + User u1 = securityRealm.createAccount("user", "password"); + u1.setFullName("A User"); + // overwrite the password property using an password created using an incorrect algorithm + u1.addProperty(Details.fromHashedPassword(PBKDF_ENDOCED_PASSWORD)); + + u1.save(); + assertThat(u1.getProperty(Details.class).getPassword(), is(PBKDF_ENDOCED_PASSWORD)); + + try (WebClient wc = j.createWebClient()) { + assertThrows(FailingHttpStatusCodeException.class, () -> wc.login("user", "password")); + } + assertThat(lr, hasIncorrectHashingLogEntry()); + } + + @Test + public void userCreationWithPBKDFPasswords() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + + IllegalArgumentException illegalArgumentException = assertThrows(IllegalArgumentException.class, + () -> securityRealm.createAccountWithHashedPassword("user_hashed_incorrect_algorithm", PBKDF_ENDOCED_PASSWORD)); + assertThat(illegalArgumentException.getMessage(), + is("The hashed password was hashed with an incorrect algorithm. Jenkins is expecting #jbcrypt:")); + } + + private static Matcher hasIncorrectHashingLogEntry() { + return LoggerRule.recorded(is( + "A password appears to be stored (or is attempting to be stored) that was created with a different hashing/encryption algorithm, check the FIPS-140 state of the system has not changed inadvertently")); + } + private User prepareRealmAndAlice() throws Exception { j.jenkins.setDisableRememberMe(false); HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); diff --git a/test/src/test/java/jenkins/model/JenkinsSecurity3073Test.java b/test/src/test/java/jenkins/model/JenkinsSecurity3073Test.java deleted file mode 100644 index 96c3fb3c634a..000000000000 --- a/test/src/test/java/jenkins/model/JenkinsSecurity3073Test.java +++ /dev/null @@ -1,77 +0,0 @@ -package jenkins.model; - -import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE; -import static java.nio.file.attribute.PosixFilePermission.OWNER_READ; -import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE; -import static org.awaitility.Awaitility.await; -import static org.junit.Assert.assertEquals; -import static org.junit.Assume.assumeFalse; - -import hudson.Functions; -import java.io.File; -import java.nio.file.Files; -import java.nio.file.LinkOption; -import java.nio.file.attribute.PosixFilePermission; -import java.util.Arrays; -import java.util.Comparator; -import java.util.EnumSet; -import java.util.HashSet; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import org.apache.commons.io.FileUtils; -import org.htmlunit.html.HtmlForm; -import org.htmlunit.html.HtmlPage; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; -import org.jvnet.hudson.test.Issue; -import org.jvnet.hudson.test.JenkinsRule; - -public class JenkinsSecurity3073Test { - - - @Rule - public JenkinsRule j = new JenkinsRule(); - - @Rule - public TemporaryFolder tmp = new TemporaryFolder(); - - @Test - @Issue("SECURITY-3073") - public void verifyUploadedFingerprintFilePermission() throws Exception { - assumeFalse(Functions.isWindows()); - - HtmlPage page = j.createWebClient().goTo("fingerprintCheck"); - // The form doesn't have a name, the page contain the search form and the one we're interested in - HtmlForm form = page.getForms().get(1); - File dir = tmp.newFolder(); - File plugin = new File(dir, "htmlpublisher.jpi"); - // We're using a plugin to have a file above DiskFileItemFactory.DEFAULT_SIZE_THRESHOLD - FileUtils.copyURLToFile(Objects.requireNonNull(getClass().getClassLoader().getResource("plugins/htmlpublisher.jpi")), plugin); - form.getInputByName("name").setValueAttribute(plugin.getAbsolutePath()); - j.submit(form); - - File filesRef = Files.createTempFile("tmp", ".tmp").toFile(); - File filesTmpDir = filesRef.getParentFile(); - filesRef.deleteOnExit(); - - final Set[] filesPermission = new Set[]{new HashSet<>()}; - await().pollInterval(250, TimeUnit.MILLISECONDS) - .atMost(10, TimeUnit.SECONDS) - .until(() -> { - Optional lastUploadedPlugin = Arrays.stream(Objects.requireNonNull( - filesTmpDir.listFiles((file, fileName) -> - fileName.startsWith("jenkins-multipart-uploads")))). - max(Comparator.comparingLong(File::lastModified)); - if (lastUploadedPlugin.isPresent()) { - filesPermission[0] = Files.getPosixFilePermissions(lastUploadedPlugin.get().toPath(), LinkOption.NOFOLLOW_LINKS); - return true; - } else { - return false; - } - }); - assertEquals(EnumSet.of(OWNER_EXECUTE, OWNER_READ, OWNER_WRITE), filesPermission[0]); - } -} diff --git a/test/src/test/java/jenkins/model/JenkinsTest.java b/test/src/test/java/jenkins/model/JenkinsTest.java index 5c0c9acedbb2..6750eabbd3f1 100644 --- a/test/src/test/java/jenkins/model/JenkinsTest.java +++ b/test/src/test/java/jenkins/model/JenkinsTest.java @@ -24,6 +24,10 @@ package jenkins.model; +import static java.nio.file.attribute.PosixFilePermission.OWNER_EXECUTE; +import static java.nio.file.attribute.PosixFilePermission.OWNER_READ; +import static java.nio.file.attribute.PosixFilePermission.OWNER_WRITE; +import static org.awaitility.Awaitility.await; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.arrayContaining; import static org.hamcrest.Matchers.containsString; @@ -38,9 +42,11 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeFalse; import edu.umd.cs.findbugs.annotations.CheckForNull; import hudson.ExtensionList; +import hudson.Functions; import hudson.XmlFile; import hudson.init.InitMilestone; import hudson.init.Initializer; @@ -67,27 +73,40 @@ import hudson.util.FormValidation; import hudson.util.HttpResponses; import hudson.util.VersionNumber; +import java.io.File; import java.io.IOException; import java.net.HttpURLConnection; import java.net.Socket; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.attribute.PosixFilePermission; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; +import java.util.EnumSet; import java.util.HashSet; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import jenkins.AgentProtocol; +import org.apache.commons.io.FileUtils; import org.htmlunit.FailingHttpStatusCodeException; import org.htmlunit.HttpMethod; import org.htmlunit.Page; import org.htmlunit.TextPage; import org.htmlunit.WebRequest; import org.htmlunit.WebResponse; +import org.htmlunit.html.HtmlForm; import org.htmlunit.html.HtmlPage; import org.junit.Rule; import org.junit.Test; import org.junit.experimental.categories.Category; +import org.junit.rules.TemporaryFolder; import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.JenkinsRule.WebClient; @@ -110,6 +129,46 @@ public class JenkinsTest { @Rule public JenkinsRule j = new JenkinsRule(); + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + @Issue("SECURITY-3073") + public void verifyUploadedFingerprintFilePermission() throws Exception { + assumeFalse(Functions.isWindows()); + + HtmlPage page = j.createWebClient().goTo("fingerprintCheck"); + // The form doesn't have a name, the page contain the search form and the one we're interested in + HtmlForm form = page.getForms().get(1); + File dir = tmp.newFolder(); + File plugin = new File(dir, "htmlpublisher.jpi"); + // We're using a plugin to have a file above DiskFileItemFactory.DEFAULT_SIZE_THRESHOLD + FileUtils.copyURLToFile(Objects.requireNonNull(getClass().getClassLoader().getResource("plugins/htmlpublisher.jpi")), plugin); + form.getInputByName("name").setValueAttribute(plugin.getAbsolutePath()); + j.submit(form); + + File filesRef = Files.createTempFile("tmp", ".tmp").toFile(); + File filesTmpDir = filesRef.getParentFile(); + filesRef.deleteOnExit(); + + final Set[] filesPermission = new Set[]{new HashSet<>()}; + await().pollInterval(250, TimeUnit.MILLISECONDS) + .atMost(10, TimeUnit.SECONDS) + .until(() -> { + Optional lastUploadedPlugin = Arrays.stream(Objects.requireNonNull( + filesTmpDir.listFiles((file, fileName) -> + fileName.startsWith("jenkins-multipart-uploads")))). + max(Comparator.comparingLong(File::lastModified)); + if (lastUploadedPlugin.isPresent()) { + filesPermission[0] = Files.getPosixFilePermissions(lastUploadedPlugin.get().toPath(), LinkOption.NOFOLLOW_LINKS); + return true; + } else { + return false; + } + }); + assertEquals(EnumSet.of(OWNER_EXECUTE, OWNER_READ, OWNER_WRITE), filesPermission[0]); + } + @Issue("SECURITY-406") @Test public void testUserCreationFromUrlForAdmins() throws Exception { diff --git a/test/src/test/java/jenkins/model/ScriptListenerTest.java b/test/src/test/java/jenkins/model/ScriptListenerTest.java new file mode 100644 index 000000000000..2b8fcee441be --- /dev/null +++ b/test/src/test/java/jenkins/model/ScriptListenerTest.java @@ -0,0 +1,182 @@ +package jenkins.model; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; + +import edu.umd.cs.findbugs.annotations.NonNull; +import groovy.lang.Binding; +import hudson.ExtensionList; +import hudson.cli.CLICommandInvoker; +import hudson.cli.GroovyCommand; +import hudson.cli.GroovyshCommand; +import hudson.model.User; +import hudson.util.RemotingDiagnostics; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.List; +import java.util.logging.Level; +import jenkins.util.DefaultScriptListener; +import jenkins.util.ScriptListener; +import org.htmlunit.HttpMethod; +import org.htmlunit.WebRequest; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.jvnet.hudson.test.TestExtension; + +public class ScriptListenerTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Rule + public LoggerRule logging = new LoggerRule(); + + @Test + public void consoleUsageIsLogged() throws IOException { + final String output = "hello from script console"; + final String script = "println '" + output + "'"; + + logging.record(DefaultScriptListener.class.getName(), Level.FINEST).capture(100); + + try (JenkinsRule.WebClient wc = j.createWebClient()) { + final WebRequest request = new WebRequest(new URL(wc.getContextPath() + "scriptText?script=" + script), HttpMethod.POST); + wc.getPage(wc.addCrumb(request)); + } + + { // DefaultScriptListener + final List messages = logging.getMessages(); + assertThat(messages, hasSize(2)); + + assertThat(messages.get(0), containsString("Execution of script: '" + script + "' with binding: '[:]' in feature: 'class hudson.util.RemotingDiagnostics' and context: 'hudson.remoting.LocalChannel@")); + assertThat(messages.get(0), containsString("' with correlation: '")); + assertThat(messages.get(0), containsString("' (no user)")); + + assertThat(messages.get(1), containsString("Script output: 'hello from script console\n' in feature: 'class hudson.util.RemotingDiagnostics' and context: 'hudson.remoting.LocalChannel@")); + assertThat(messages.get(1), containsString("' with correlation: '")); + assertThat(messages.get(1), containsString("' (no user)")); + } + + { // DummyScriptUsageListener + final DummyScriptUsageListener listener = ExtensionList.lookupSingleton(DummyScriptUsageListener.class); + String execution = listener.getExecutionString(); + + assertThat(execution, containsString(RemotingDiagnostics.class.getName())); + assertThat(execution, containsString(script)); + assertThat(listener.getOutput(), containsString(output)); + } + } + + @Test + public void groovyCliUsageIsLogged() { + final String output = "hello from groovy CLI"; + final String script = "println '" + output + "'"; + + logging.record(DefaultScriptListener.class.getName(), Level.FINEST).capture(100); + + InputStream scriptStream = new ByteArrayInputStream(script.getBytes()); + new CLICommandInvoker(j, "groovy").withArgs("=").withStdin(scriptStream).invoke(); + + { // DefaultScriptListener + final List messages = logging.getMessages(); + assertThat(messages, hasSize(3)); + + assertThat(messages.get(0), containsString("Execution of script: '" + script + "' with binding: '[")); + assertThat(messages.get(0), containsString("]' in feature: 'class hudson.cli.GroovyCommand' and context: 'null' with correlation: '")); + assertThat(messages.get(0), containsString("' (no user)")); + + assertThat(messages.get(1), containsString("Script output: 'hello from groovy CLI' in feature: 'class hudson.cli.GroovyCommand' and context: 'null' with correlation: '")); + assertThat(messages.get(1), containsString("' (no user)")); + + assertThat(messages.get(2), containsString("Script output: '\n' in feature: 'class hudson.cli.GroovyCommand' and context: 'null' with correlation: '")); + assertThat(messages.get(2), containsString("' (no user)")); + } + + { // DummyScriptUsageListener + final DummyScriptUsageListener listener = ExtensionList.lookupSingleton(DummyScriptUsageListener.class); + String execution = listener.getExecutionString(); + + assertThat(execution, containsString(GroovyCommand.class.getName())); + assertThat(execution, containsString(script)); + assertThat(listener.getOutput(), containsString(output)); + } + } + + @Test + public void groovyShCliUsageIsLogged() { + final String output = "hello from groovysh CLI"; + final String script = "println '" + output + "'"; + + logging.record(DefaultScriptListener.class.getName(), Level.FINEST).capture(100); + + InputStream scriptStream = new ByteArrayInputStream(script.getBytes()); + new CLICommandInvoker(j, "groovysh").withStdin(scriptStream).invoke(); + + { // DefaultScriptListener + final List messages = logging.getMessages(); + assertThat(messages, hasSize(9)); + + assertThat(messages.get(0), containsString("Execution of script: 'null' with binding: 'null' in feature: 'class hudson.cli.GroovyshCommand' and context: 'null' with correlation: '")); + assertThat(messages.get(0), containsString("' (no user)")); + + // Only match short substrings to not have to deal with color escape codes in the output + assertThat(messages.get(1), containsString("Groovy Shell")); // Groovy Shell (2.4.21, JVM: 11.0.15) + assertThat(messages.get(2), containsString(":help")); // Type ':help' or ':h' for help. + assertThat(messages.get(3), containsString("Script output: '-------------------")); + assertThat(messages.get(4), containsString("000")); // groovy:000> + + assertThat(messages.get(5), containsString("Execution of script: '" + script + "' with binding: '[")); + assertThat(messages.get(5), containsString("]' in feature: 'class hudson.cli.GroovyshCommand' and context: 'null' with correlation: '")); + assertThat(messages.get(5), containsString("' (no user)")); + + assertThat(messages.get(6), containsString("Script output: 'hello from groovysh CLI\n' in feature: 'class hudson.cli.GroovyshCommand' and context: 'null' with correlation: '")); + assertThat(messages.get(6), containsString("' (no user)")); + + // Only match short substrings to not have to deal with color escape codes in the output + assertThat(messages.get(7), containsString("===>")); // ===> null + assertThat(messages.get(8), containsString("000")); // groovy:000> + } + + { // DummyScriptUsageListener + final DummyScriptUsageListener listener = ExtensionList.lookupSingleton(DummyScriptUsageListener.class); + String execution = listener.getExecutionString(); + + assertThat(execution, containsString(GroovyshCommand.class.getName())); + assertThat(execution, containsString(script)); + assertThat(listener.getOutput(), containsString(output)); + } + } + + @TestExtension + public static class DummyScriptUsageListener implements ScriptListener { + private final StringBuilder script = new StringBuilder(); + private final StringBuilder output = new StringBuilder(); + + @Override + public void onScriptExecution(String script, Binding binding, @NonNull Object feature, Object context, @NonNull String correlationId, User u) { + String username = "null"; + if (u != null) { + username = u.getFullName(); + } + String expectedOutFormat = "Script: '%s' in '%s' with '%s' by '%s'"; + this.script.append(String.format(expectedOutFormat, script, feature, context, correlationId, username)).append("\n"); + } + + @Override + public void onScriptOutput(String output, @NonNull Object feature, Object context, @NonNull String correlationId, User user) { + this.output.append(output); + } + + String getExecutionString() { + return script.toString(); + } + + String getOutput() { + return output.toString(); + } + } +} diff --git a/war/src/main/webapp/scripts/hudson-behavior.js b/war/src/main/webapp/scripts/hudson-behavior.js index cd35a3cd6032..7284a14e2ba1 100644 --- a/war/src/main/webapp/scripts/hudson-behavior.js +++ b/war/src/main/webapp/scripts/hudson-behavior.js @@ -639,17 +639,7 @@ function updateValidationArea(validationArea, content) { // Only change content if different, causes an unnecessary animation otherwise if (validationArea.innerHTML !== content) { validationArea.innerHTML = content; - validationArea.style.height = - validationArea.children[0].offsetHeight + "px"; - - // Only include the notice in the validation-error-area, move all other elements out - if (validationArea.children.length > 1) { - Array.from(validationArea.children) - .slice(1) - .forEach((element) => { - validationArea.after(element); - }); - } + validationArea.style.height = "auto"; Behaviour.applySubtree(validationArea); // For errors with additional details, apply the subtree to the expandable details pane