diff --git a/apiserver/src/main/java/org/dependencytrack/plugin/PluginInitializer.java b/apiserver/src/main/java/org/dependencytrack/plugin/PluginInitializer.java index 0f9589fd55..b7ebdd483b 100644 --- a/apiserver/src/main/java/org/dependencytrack/plugin/PluginInitializer.java +++ b/apiserver/src/main/java/org/dependencytrack/plugin/PluginInitializer.java @@ -20,6 +20,8 @@ import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.ConfigProvider; import org.dependencytrack.filestorage.api.FileStorage; import org.dependencytrack.notification.api.publishing.NotificationPublisher; import org.dependencytrack.plugin.api.Plugin; @@ -82,6 +84,10 @@ public void contextInitialized(ServletContextEvent event) { LOGGER.debug("Discovered plugin {}", plugin.getClass().getName()); } + if (config.getValue("plugin.external.load.enabled", boolean.class)) { + pluginManager.setExternalPluginConfig(true, config.getValue("plugin.external.dir", String.class)); + } + LOGGER.info("Loading plugins"); pluginManager.loadPlugins(plugins); diff --git a/apiserver/src/main/java/org/dependencytrack/plugin/PluginIsolatedClassLoader.java b/apiserver/src/main/java/org/dependencytrack/plugin/PluginIsolatedClassLoader.java new file mode 100644 index 0000000000..7b22d27b1d --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/plugin/PluginIsolatedClassLoader.java @@ -0,0 +1,126 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Enumeration; +import java.util.List; +import java.util.Objects; + +/** + * Child-first classloader with configurable shared package prefixes. + */ +public final class PluginIsolatedClassLoader extends URLClassLoader { + + private final ClassLoader hostClassLoader; + private final List sharedPackagePrefixes; + + /** + * @param urls URLs pointing to plugin JAR(s). + * @param hostClassLoader classloader to supply shared API classes. + * @param sharedPackagePrefixes list of package prefixes that must be loaded from host. + */ + public PluginIsolatedClassLoader(final URL[] urls, final ClassLoader hostClassLoader, final List sharedPackagePrefixes) { + super(urls, null); + this.hostClassLoader = Objects.requireNonNull(hostClassLoader, "hostClassLoader"); + this.sharedPackagePrefixes = List.copyOf(Objects.requireNonNull(sharedPackagePrefixes, "sharedPackagePrefixes")); + } + + private boolean isShared(final String className) { + for (final String prefix : sharedPackagePrefixes) { + if (className.startsWith(prefix)) { + return true; + } + } + return false; + } + + @Override + protected synchronized Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException { + // delegate to host + if (isShared(name)) { + return hostClassLoader.loadClass(name); + } + + Class loaded = findLoadedClass(name); + if (loaded != null) { + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + + // Try to load from plugin JAR + try { + Class clazz = findClass(name); + if (resolve) { + resolveClass(clazz); + } + return clazz; + } catch (ClassNotFoundException ignored) { + } + + // Fallback to host classloader + return hostClassLoader.loadClass(name); + } + + @Override + public URL getResource(final String name) { + final String dotted = name.replace('/', '.'); + for (final String prefix : sharedPackagePrefixes) { + if (dotted.startsWith(prefix)) { + return hostClassLoader.getResource(name); + } + } + + final URL url = findResource(name); + if (url != null) { + return url; + } + return hostClassLoader.getResource(name); + } + + @Override + public Enumeration getResources(final String name) throws IOException { + final Enumeration pluginResources = findResources(name); + final Enumeration hostResources = hostClassLoader.getResources(name); + + return new Enumeration<>() { + @Override + public boolean hasMoreElements() { + return pluginResources.hasMoreElements() || hostResources.hasMoreElements(); + } + + @Override + public URL nextElement() { + if (pluginResources.hasMoreElements()) { + return pluginResources.nextElement(); + } + return hostResources.nextElement(); + } + }; + } + + @Override + public void close() throws IOException { + super.close(); + } +} diff --git a/apiserver/src/main/java/org/dependencytrack/plugin/PluginManager.java b/apiserver/src/main/java/org/dependencytrack/plugin/PluginManager.java index abc13926d8..b2fa7b605c 100644 --- a/apiserver/src/main/java/org/dependencytrack/plugin/PluginManager.java +++ b/apiserver/src/main/java/org/dependencytrack/plugin/PluginManager.java @@ -36,8 +36,14 @@ import org.jspecify.annotations.Nullable; import org.slf4j.MDC; +import java.io.IOException; import java.io.Closeable; import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -51,10 +57,12 @@ import java.util.SequencedMap; import java.util.Set; import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; import java.util.regex.Pattern; +import java.util.stream.Stream; import static java.util.Objects.requireNonNull; import static org.dependencytrack.common.MdcKeys.MDC_EXTENSION; @@ -89,6 +97,12 @@ public class PluginManager implements Closeable { private final AtomicBoolean closed = new AtomicBoolean(); private final ReentrantLock lock; + // Map of each plugin class to its ClassLoader + private final Map, ClassLoader> pluginClassToClassLoader = new ConcurrentHashMap<>(); + private final Map externalPluginLoaders = new ConcurrentHashMap<>(); + private boolean externalPluginsEnabled = false; + private Path externalPluginDir; + public PluginManager( Config config, Function secretResolver, @@ -293,11 +307,70 @@ private void loadPluginsLocked(Collection plugins) { } } + if (externalPluginsEnabled) { + LOGGER.info("Discovering external plugins in: %s".formatted(externalPluginDir)); + loadExternalPlugins(externalPluginDir); + } else { + LOGGER.info("External plugin loading disabled — skipping external scan."); + } + determineDefaultExtensions(); assertRequiredExtensionPoints(); } + private void loadExternalPlugins(final Path externalPluginDir) { + try (Stream jars = Files.list(externalPluginDir) + .filter(path -> path.toString().endsWith(".jar"))) { + jars.forEach(this::loadExternalPluginJar); + } catch (IOException e) { + LOGGER.warn("Failed to scan external plugin directory: %s".formatted(externalPluginDir), e); + } + } + + private void loadExternalPluginJar(final Path jarPath) { + try (var ignoredMdcPlugin = MDC.putCloseable(MDC_PLUGIN, jarPath.getFileName().toString())) { + + final URL jarUrl = jarPath.toUri().toURL(); + + // Host classloader to load the Plugin API + final ClassLoader hostClassLoader = Plugin.class.getClassLoader(); + + // Shared package prefixes + final List sharedPackages = List.of( + "org.dependencytrack.plugin.api." + ); + + final PluginIsolatedClassLoader loader = new PluginIsolatedClassLoader( + new URL[]{ jarUrl }, hostClassLoader, sharedPackages); + + externalPluginLoaders.put(loader, jarPath); + final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + + try { + Thread.currentThread().setContextClassLoader(loader); + + final ServiceLoader pluginServiceLoader = ServiceLoader.load(Plugin.class, loader); + for (final Plugin plugin : pluginServiceLoader) { + try (var ignored = MDC.putCloseable(MDC_PLUGIN, plugin.getClass().getName())) { + LOGGER.debug("Loading external plugin %s".formatted(plugin.getClass().getName())); + loadExtensionsForPlugin(plugin); + loadedPluginByClass.put(plugin.getClass(), plugin); + + // Map the plugin class to its loader for unloading + pluginClassToClassLoader.put(plugin.getClass(), loader); + LOGGER.info("External plugin loaded successfully: %s".formatted(plugin.getClass().getName())); + } + } + } finally { + Thread.currentThread().setContextClassLoader(contextClassLoader); + } + + } catch (Exception e) { + LOGGER.error("Failed to load external plugin from JAR %s".formatted(jarPath), e); + } + } + private void loadExtensionsForPlugin(Plugin plugin) { final Collection> extensionFactories = plugin.extensionFactories(); if (extensionFactories == null || extensionFactories.isEmpty()) { @@ -527,6 +600,7 @@ public void close() { lock.lock(); try { unloadPluginsLocked(); + closeExternalPluginLoaders(); defaultFactoryByExtensionPointClass.clear(); configRegistryByExtensionIdentity.clear(); factoryByExtensionIdentity.clear(); @@ -535,6 +609,7 @@ public void close() { factoriesByPlugin.clear(); pluginByExtensionIdentity.clear(); loadedPluginByClass.clear(); + pluginClassToClassLoader.clear(); } finally { lock.unlock(); } @@ -584,4 +659,25 @@ private void unloadPlugin(Plugin plugin) { } } + private void closeExternalPluginLoaders() { + // Close all external loaders if any + for (ClassLoader loader : new ArrayList<>(externalPluginLoaders.keySet())) { + try { + if (loader instanceof PluginIsolatedClassLoader) { + ((PluginIsolatedClassLoader) loader).close(); + } else if (loader instanceof URLClassLoader) { + ((URLClassLoader) loader).close(); + } + } catch (IOException e) { + LOGGER.warn("Failed to close plugin classloader for %s: %s".formatted(externalPluginLoaders.get(loader), e.getMessage())); + } finally { + externalPluginLoaders.remove(loader); + } + } + } + + public void setExternalPluginConfig(boolean enabled, String directory) { + this.externalPluginsEnabled = enabled; + this.externalPluginDir = Paths.get(directory); + } } diff --git a/apiserver/src/main/resources/application.properties b/apiserver/src/main/resources/application.properties index df1f09eba4..fa2c6a5b1e 100644 --- a/apiserver/src/main/resources/application.properties +++ b/apiserver/src/main/resources/application.properties @@ -1800,6 +1800,11 @@ dt.file-storage.s3.enabled=false # @default: 5 # @type: integer # @valid-values: [-7..22] +# file.storage.extension.s3.compression.level= + +plugin.external.load.enabled=false +plugin.external.dir= org/dependencytrack/plugin/external/ + # dt.file-storage.s3.compression.level= # Defines whether the console notification publisher is enabled. diff --git a/apiserver/src/test/java/org/dependencytrack/plugin/PluginManagerExternalPluginTest.java b/apiserver/src/test/java/org/dependencytrack/plugin/PluginManagerExternalPluginTest.java new file mode 100644 index 0000000000..d7fef85751 --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/plugin/PluginManagerExternalPluginTest.java @@ -0,0 +1,100 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import org.dependencytrack.PersistenceCapableTest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PluginManagerExternalPluginTest extends PersistenceCapableTest { + + private PluginManager pluginManager; + + // Source code for external plugin + private static final String TEST_PLUGIN_SOURCE = """ + package org.dependencytrack.plugin; + + import org.dependencytrack.plugin.api.ExtensionFactory; + import org.dependencytrack.plugin.api.ExtensionPoint; + import org.dependencytrack.plugin.api.Plugin; + import java.util.Collection; + + public class MyExternalPlugin implements Plugin { + @Override + public Collection> extensionFactories() { + return java.util.Collections.emptyList(); + } + } + """; + + @Before + @Override + public void before() throws Exception { + super.before(); + pluginManager = PluginManager.getInstance(); + pluginManager.unloadPlugins(); + } + + @After + @Override + public void after() { + pluginManager.unloadPlugins(); + super.after(); + } + + @Test + public void shouldNotLoadExternalPluginsWhenDisabled() throws Exception { + Path tempDir = Files.createTempDirectory("plugins"); + TestPluginJarBuilder.buildTestPluginJar(tempDir, "MyExternalPlugin", TEST_PLUGIN_SOURCE); + + pluginManager.setExternalPluginConfig(false, tempDir.toString()); + pluginManager.loadPlugins(); + + assertThat(pluginManager.getLoadedPlugins()) + .noneMatch(p -> p.getClass().getSimpleName().equals("MyExternalPlugin")); + } + + @Test + public void shouldLoadExternalPluginWhenEnabled() throws Exception { + Path tempDir = Files.createTempDirectory("plugins"); + TestPluginJarBuilder.buildTestPluginJar(tempDir, "MyExternalPlugin", TEST_PLUGIN_SOURCE); + + // Enable external plugins + pluginManager.setExternalPluginConfig(true, tempDir.toString()); + pluginManager.loadPlugins(); + + // Verify the plugin was loaded + assertThat(pluginManager.getLoadedPlugins()) + .anyMatch(p -> p.getClass().getSimpleName().equals("MyExternalPlugin")); + + var plugin = pluginManager.getLoadedPlugins().stream() + .filter(p -> p.getClass().getSimpleName().equals("MyExternalPlugin")) + .findFirst() + .orElseThrow(); + + // Verify the plugin classloader is isolated + assertThat(plugin.getClass().getClassLoader()).isInstanceOf(PluginIsolatedClassLoader.class); + } +} diff --git a/apiserver/src/test/java/org/dependencytrack/plugin/TestPluginJarBuilder.java b/apiserver/src/test/java/org/dependencytrack/plugin/TestPluginJarBuilder.java new file mode 100644 index 0000000000..3be9d54180 --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/plugin/TestPluginJarBuilder.java @@ -0,0 +1,70 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.plugin; + +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +public class TestPluginJarBuilder { + + static Path buildTestPluginJar(final Path outputDir, final String className, final String sourceCode) throws IOException { + Path packageDir = outputDir.resolve("org/dependencytrack/plugin"); + Files.createDirectories(packageDir); + Path srcFile = packageDir.resolve(className + ".java"); + Files.writeString(srcFile, sourceCode); + + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) throw new IllegalStateException("No system Java compiler found."); + + String classpath = System.getProperty("java.class.path"); + + int result = compiler.run( + null, null, null, + "-classpath", classpath, + "-d", outputDir.toString(), + srcFile.toString() + ); + + if (result != 0) throw new IllegalStateException("Compilation failed for " + className); + + Path compiledClass = packageDir.resolve(className + ".class"); + if (!Files.exists(compiledClass)) throw new IllegalStateException("Compiled class not found: " + compiledClass); + + Path jarFile = outputDir.resolve(className + ".jar"); + try (JarOutputStream jar = new JarOutputStream(Files.newOutputStream(jarFile))) { + + // Add plugin class + jar.putNextEntry(new JarEntry("org/dependencytrack/plugin/" + className + ".class")); + Files.copy(compiledClass, jar); + jar.closeEntry(); + + // Add SPI file + jar.putNextEntry(new JarEntry("META-INF/services/org.dependencytrack.plugin.api.Plugin")); + jar.write(("org.dependencytrack.plugin." + className).getBytes()); + jar.closeEntry(); + } + + return jarFile; + } +}