From 6c7b2d91769fe6c5188a72fd47647e9a30c25a6c Mon Sep 17 00:00:00 2001 From: Mitch Gaffigan Date: Thu, 27 Aug 2026 13:52:02 -0500 Subject: [PATCH] Add password scanner Signed-off-by: Mitch Gaffigan --- server/build.gradle | 6 +- server/conf/log4j2.properties | 2 + .../passwordscanner/PasswordScanner.java | 89 ++++++++++++ .../PasswordScannerService.java | 129 ++++++++++++++++++ .../plugins/passwordscanner/Wordlist.java | 115 ++++++++++++++++ .../plugins/passwordscanner/plugin.xml | 12 ++ .../plugins/passwordscanner/WordlistTest.java | 77 +++++++++++ 7 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 server/src/main/java/com/mirth/connect/plugins/passwordscanner/PasswordScanner.java create mode 100644 server/src/main/java/com/mirth/connect/plugins/passwordscanner/PasswordScannerService.java create mode 100644 server/src/main/java/com/mirth/connect/plugins/passwordscanner/Wordlist.java create mode 100644 server/src/main/resources/com/mirth/connect/plugins/passwordscanner/plugin.xml create mode 100644 server/src/test/java/com/mirth/connect/plugins/passwordscanner/WordlistTest.java diff --git a/server/build.gradle b/server/build.gradle index a8c38cb94c..d0ec9976bb 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -370,6 +370,10 @@ def serverExtensions = [ server: [includes: ['com/mirth/connect/plugins/datapruner/**'], excludes: ['com/mirth/connect/plugins/datapruner/DataPrunerServletInterface.class']]], + [name: 'passwordscanner', pkg: 'com/mirth/connect/plugins/passwordscanner', lib: 'passwordscanner', + shared: null, + server: [includes: ['com/mirth/connect/plugins/passwordscanner/**']]], + [name: 'mllpmode', pkg: 'com/mirth/connect/plugins/mllpmode', lib: 'mllpmode', shared: [includes: ['com/mirth/connect/plugins/mllpmode/MLLPModeProperties.class']], server: [includes: ['com/mirth/connect/plugins/mllpmode/**'], @@ -857,7 +861,7 @@ def zippedExtensions = ['jms', 'jdbc', 'dicom', 'http', 'doc', 'smtp', 'tcp', 'f 'destinationsetfilter', 'serverlog', 'datapruner', 'javascriptstep', 'mapper', 'messagebuilder', 'scriptfilestep', 'rulebuilder', 'javascriptrule', 'dicomviewer', 'pdfviewer', 'textviewer', 'httpauth', - 'imageviewer', 'globalmapviewer'] + 'imageviewer', 'globalmapviewer', 'passwordscanner'] // The zip names carry the version, so clear the directory first the way // Ant did; otherwise zips from previous versions accumulate. diff --git a/server/conf/log4j2.properties b/server/conf/log4j2.properties index 5afd444980..dc16433188 100644 --- a/server/conf/log4j2.properties +++ b/server/conf/log4j2.properties @@ -35,6 +35,8 @@ logger.recoveryTask.name = com.mirth.connect.donkey.server.channel.RecoveryTask logger.recoveryTask.level = INFO logger.fileReceiver.name = com.mirth.connect.connectors.file.FileReceiver logger.fileReceiver.level = WARN +logger.passwordScanner.name = com.mirth.connect.plugins.passwordscanner +logger.passwordScanner.level = INFO # Mirth Connect channel logging logger.transformer.name = transformer diff --git a/server/src/main/java/com/mirth/connect/plugins/passwordscanner/PasswordScanner.java b/server/src/main/java/com/mirth/connect/plugins/passwordscanner/PasswordScanner.java new file mode 100644 index 0000000000..d082b8dfe2 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/plugins/passwordscanner/PasswordScanner.java @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Mitch Gaffigan + +package com.mirth.connect.plugins.passwordscanner; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.model.Credentials; +import com.mirth.connect.model.User; +import com.mirth.connect.server.controllers.ControllerFactory; +import com.mirth.connect.server.controllers.UserController; +import com.mirth.connect.server.util.Pre22PasswordChecker; + +/** + * Checks each user's current password against a handful of trivially guessable passwords and logs a + * warning for every match. The classic case is a stock admin/admin account. + */ +public class PasswordScanner { + + /** Pause between checks. Nothing here is urgent, so stay out of the way of real work. */ + private static final long PAUSE_MILLIS = 1000; + + private Logger logger = LogManager.getLogger(this.getClass()); + private UserController userController = ControllerFactory.getFactory().createUserController(); + private Wordlist wordlist; + + public PasswordScanner(Wordlist wordlist) { + this.wordlist = wordlist; + } + + public void scan() throws ControllerException, InterruptedException { + for (User user : userController.getAllUsers()) { + if (Thread.currentThread().isInterrupted()) { + return; + } + + if (hasTrivialPassword(user)) { + logger.warn("User \"{}\" has a trivially guessable password and should change it immediately.", user.getUsername()); + } + } + } + + private boolean hasTrivialPassword(User user) throws ControllerException, InterruptedException { + List credentials = userController.getUserCredentials(user.getId()); + if (credentials.isEmpty()) { + return false; + } + + // Credentials are ordered newest first, so the first entry is the password in use. + String hash = credentials.get(0).getPassword(); + + // Check against the wordlist + List candidates = wordlist.getPasswords(); + for (String candidate : candidates) { + if (matches(candidate, hash)) { + return true; + } + } + + // Check against the username, unless it's already in the wordlist + if (!candidates.contains(user.getUsername())) { + return matches(user.getUsername(), hash); + } + + return false; + } + + private boolean matches(String plainPassword, String hash) { + try { + // Throttle to avoid heavy CPU load. Hashing is expensive by design. + Thread.sleep(PAUSE_MILLIS); + + if (Pre22PasswordChecker.isPre22Hash(hash)) { + return Pre22PasswordChecker.checkPassword(plainPassword, hash); + } + + return userController.checkPassword(plainPassword, hash); + } catch (Exception e) { + logger.debug("Unable to check a password against its hash.", e); + return false; + } + } +} diff --git a/server/src/main/java/com/mirth/connect/plugins/passwordscanner/PasswordScannerService.java b/server/src/main/java/com/mirth/connect/plugins/passwordscanner/PasswordScannerService.java new file mode 100644 index 0000000000..5ec1b2708d --- /dev/null +++ b/server/src/main/java/com/mirth/connect/plugins/passwordscanner/PasswordScannerService.java @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Mitch Gaffigan + +package com.mirth.connect.plugins.passwordscanner; + +import java.util.Properties; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.lang3.math.NumberUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.mirth.connect.model.ExtensionPermission; +import com.mirth.connect.plugins.ServicePlugin; + +/** + * Periodically runs the {@link PasswordScanner} on a background thread. + */ +public class PasswordScannerService implements ServicePlugin { + + public static final String PLUGINPOINT = "Password Scanner"; + + private static final String INTERVAL_SECONDS = "intervalSeconds"; + private static final int DEFAULT_INTERVAL_SECONDS = (int) TimeUnit.DAYS.toSeconds(1); + private static final String PASSWORDS = "passwords"; + + /** Delay the first scan so it doesn't compete with the rest of server startup. */ + private static final long STARTUP_DELAY_SECONDS = 30; + private static final long MIN_INTERVAL_SECONDS = 300; + + private Logger logger = LogManager.getLogger(this.getClass()); + private ScheduledExecutorService executor; + private int intervalSeconds = DEFAULT_INTERVAL_SECONDS; + private volatile Wordlist wordlist = Wordlist.DEFAULT; + + @Override + public String getPluginPointName() { + return PLUGINPOINT; + } + + @Override + public void init(Properties properties) { + readProperties(properties); + } + + @Override + public synchronized void update(Properties properties) { + stop(); + readProperties(properties); + start(); + } + + @Override + public synchronized void start() { + if (executor != null) { + return; + } + + executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, PLUGINPOINT); + thread.setDaemon(true); + // Scanning passwords is housekeeping; it should always lose to real work. + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + } + }); + + executor.scheduleWithFixedDelay(new Runnable() { + @Override + public void run() { + scan(); + } + }, STARTUP_DELAY_SECONDS, intervalSeconds, TimeUnit.SECONDS); + } + + @Override + public synchronized void stop() { + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + } + + @Override + public Properties getDefaultProperties() { + Properties properties = new Properties(); + properties.put(INTERVAL_SECONDS, Integer.toString(DEFAULT_INTERVAL_SECONDS)); + properties.put(PASSWORDS, Wordlist.DEFAULT.toString()); + return properties; + } + + @Override + public ExtensionPermission[] getExtensionPermissions() { + return new ExtensionPermission[0]; + } + + private synchronized void readProperties(Properties properties) { + int seconds = NumberUtils.toInt(properties.getProperty(INTERVAL_SECONDS), DEFAULT_INTERVAL_SECONDS); + if (seconds < MIN_INTERVAL_SECONDS) { + logger.warn("Invalid {} value \"{}\".", INTERVAL_SECONDS, properties.getProperty(INTERVAL_SECONDS)); + } else { + this.intervalSeconds = seconds; + } + + Wordlist list = Wordlist.parse(properties.getProperty(PASSWORDS)); + if (!list.isEmpty()) { + this.wordlist = list; + } + } + + /** + * A scheduled task that throws is never run again, so nothing may escape here. + */ + private void scan() { + try { + new PasswordScanner(wordlist).scan(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.debug("Password scan interrupted."); + } catch (Throwable t) { + logger.error("Error scanning for trivial user passwords.", t); + } + } +} diff --git a/server/src/main/java/com/mirth/connect/plugins/passwordscanner/Wordlist.java b/server/src/main/java/com/mirth/connect/plugins/passwordscanner/Wordlist.java new file mode 100644 index 0000000000..3bd14a1cd4 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/plugins/passwordscanner/Wordlist.java @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Mitch Gaffigan + +package com.mirth.connect.plugins.passwordscanner; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +/** + * The passwords the scanner checks, held in a plugin property as a single comma-separated string. + * Escape , and \ characters with a backslash. Leading and trailing whitespace is ignored. + */ +public class Wordlist { + + private static final char SEPARATOR = ','; + private static final char ESCAPE = '\\'; + + public static final Wordlist DEFAULT = new Wordlist(Arrays.asList( + "admin", "password", "Password1", "changeme", "mirth", "letmein", + "welcome", "123456", "admin123", "password1" + )); + + private List passwords; + + public Wordlist(List passwords) { + this.passwords = Collections.unmodifiableList(new ArrayList(passwords)); + } + + /** The passwords to check, in the order they were configured. */ + public List getPasswords() { + return passwords; + } + + public boolean isEmpty() { + return passwords.isEmpty(); + } + + public static Wordlist parse(String value) { + List passwords = new ArrayList(); + StringBuilder password = new StringBuilder(); + /* + * Index just past the last character that has to be kept. Trailing whitespace sits beyond + * it and is dropped when the password is cut, unless it was escaped. + */ + int end = 0; + boolean escaped = false; + + for (char c : StringUtils.defaultString(value).toCharArray()) { + if (escaped) { + password.append(c); + end = password.length(); + escaped = false; + } else if (c == ESCAPE) { + escaped = true; + } else if (c == SEPARATOR) { + addPassword(passwords, password, end); + password.setLength(0); + end = 0; + } else if (Character.isWhitespace(c)) { + // Leading whitespace never makes it into the password at all. + if (password.length() > 0) { + password.append(c); + } + } else { + password.append(c); + end = password.length(); + } + } + + if (escaped) { + // A dangling escape at the very end can only have meant a literal backslash. + password.append(ESCAPE); + end = password.length(); + } + + addPassword(passwords, password, end); + return new Wordlist(passwords); + } + + private static void addPassword(List passwords, StringBuilder password, int end) { + if (end > 0) { + passwords.add(password.substring(0, end)); + } + } + + /** Renders a property value that {@link #parse} reads back as this word list. */ + @Override + public String toString() { + StringBuilder value = new StringBuilder(); + + for (String password : passwords) { + if (value.length() > 0) { + value.append(SEPARATOR); + } + + for (int i = 0; i < password.length(); i++) { + char c = password.charAt(i); + boolean edge = (i == 0 || i == password.length() - 1); + + // Interior whitespace survives parsing untouched, so only the edges need escaping. + if (c == SEPARATOR || c == ESCAPE || (edge && Character.isWhitespace(c))) { + value.append(ESCAPE); + } + + value.append(c); + } + } + + return value.toString(); + } +} diff --git a/server/src/main/resources/com/mirth/connect/plugins/passwordscanner/plugin.xml b/server/src/main/resources/com/mirth/connect/plugins/passwordscanner/plugin.xml new file mode 100644 index 0000000000..4c46ae683e --- /dev/null +++ b/server/src/main/resources/com/mirth/connect/plugins/passwordscanner/plugin.xml @@ -0,0 +1,12 @@ + + Password Scanner + Open Integration Engine + @mirthversion + @mirthversion + https://openintegrationengine.org + Checks user accounts for trivially guessable passwords and logs a warning for each one it finds. + + com.mirth.connect.plugins.passwordscanner.PasswordScannerService + + + diff --git a/server/src/test/java/com/mirth/connect/plugins/passwordscanner/WordlistTest.java b/server/src/test/java/com/mirth/connect/plugins/passwordscanner/WordlistTest.java new file mode 100644 index 0000000000..ce55bb1cb4 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/plugins/passwordscanner/WordlistTest.java @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Mitch Gaffigan + +package com.mirth.connect.plugins.passwordscanner; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; + +public class WordlistTest { + + @Test + public void testParse() { + assertEquals(Arrays.asList("admin", "password"), Wordlist.parse("admin,password").getPasswords()); + } + + @Test + public void testParseTrimsUnescapedWhitespace() { + assertEquals(Arrays.asList("admin", "password"), Wordlist.parse(" admin ,\tpassword\t").getPasswords()); + } + + @Test + public void testParseKeepsInteriorWhitespace() { + assertEquals(Collections.singletonList("correct horse"), Wordlist.parse("correct horse").getPasswords()); + } + + @Test + public void testParseDropsEmptyEntries() { + assertEquals(Arrays.asList("admin", "password"), Wordlist.parse(",admin,, ,password,").getPasswords()); + } + + @Test + public void testParseEmptyValue() { + assertTrue(Wordlist.parse("").isEmpty()); + assertTrue(Wordlist.parse(" ").isEmpty()); + assertTrue(Wordlist.parse(null).isEmpty()); + } + + @Test + public void testParseEscapedSeparator() { + assertEquals(Arrays.asList("a,b", "c"), Wordlist.parse("a\\,b,c").getPasswords()); + } + + @Test + public void testParseEscapedEscape() { + assertEquals(Arrays.asList("a\\b", "c"), Wordlist.parse("a\\\\b,c").getPasswords()); + } + + @Test + public void testParseEscapedWhitespaceIsKept() { + assertEquals(Collections.singletonList(" admin "), Wordlist.parse("\\ admin\\ ").getPasswords()); + } + + @Test + public void testParseTrailingEscapeIsLiteral() { + assertEquals(Collections.singletonList("admin\\"), Wordlist.parse("admin\\").getPasswords()); + } + + @Test + public void testRoundTrip() { + List passwords = Arrays.asList("admin", "a,b", "a\\b", " padded ", "correct horse", "\\", ","); + Wordlist wordlist = new Wordlist(passwords); + + assertEquals(passwords, Wordlist.parse(wordlist.toString()).getPasswords()); + } + + @Test + public void testDefaultRoundTrips() { + assertEquals(Wordlist.DEFAULT.getPasswords(), Wordlist.parse(Wordlist.DEFAULT.toString()).getPasswords()); + assertTrue(Wordlist.DEFAULT.getPasswords().contains("admin")); + } +}