Verify signatures on official plugins (#30800)

We sign our official plugins yet this is not well-advertised and not at
all consumed during plugin installation. For plugins that are installed
over the intertubes, verifying that the downloaded artifact is signed by
our signing key would establish both integrity and validity of the
downloaded artifact. The chain of trust here is simple: our installable
artifacts (archive and package distributions) so that if a user trusts
our packages via their signatures, and our plugin installer (which would
be executing trusted code) verifies the downloaded plugin, then the user
can trust the downloaded plugin too. This commit adds verification of
official plugins downloaded during installation. We do not add
verification for offline plugin installs; a user can download our
signatures and verify the artifacts themselves.

This commit also needs to solve a few interesting challenges. One of
these is that we want the bouncy castle JARs on the classpath only for
the plugin installer, but not for the runtime
Elasticsearch. Additionally, we want these JARs to not be present for
the JAR hell checks. To address this, we shift these JARs into a
sub-directory of lib (lib/tools/plugin-cli) that is only loaded for the
plugin installer, and in the plugin installer we filter any JARs in this
directory from the JAR hell check.
This commit is contained in:
Jason Tedor 2018-05-25 07:56:35 -04:00 committed by GitHub
parent adc2d408d3
commit d31e10a87d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 458 additions and 59 deletions

View File

@ -49,7 +49,9 @@ task createPluginsDir(type: EmptyDirTask) {
CopySpec archiveFiles(CopySpec modulesFiles, String distributionType, boolean oss) {
return copySpec {
into("elasticsearch-${version}") {
with libFiles
into('lib') {
with libFiles
}
into('config') {
dirMode 0750
fileMode 0660

View File

@ -227,13 +227,15 @@ configure(subprojects.findAll { ['archives', 'packages'].contains(it.name) }) {
* Common files in all distributions *
*****************************************************************************/
libFiles = copySpec {
into 'lib'
// delay by using closures, since they have not yet been configured, so no jar task exists yet
from { project(':server').jar }
from { project(':server').configurations.runtime }
from { project(':libs:plugin-classloader').jar }
// delay add tools using closures, since they have not yet been configured, so no jar task exists yet
from { project(':distribution:tools:launchers').jar }
from { project(':distribution:tools:plugin-cli').jar }
into('tools/plugin-cli') {
from { project(':distribution:tools:plugin-cli').jar }
from { project(':distribution:tools:plugin-cli').configurations.runtime }
}
}
modulesFiles = { oss ->

View File

@ -124,13 +124,23 @@ Closure commonPackageConfig(String type, boolean oss) {
include 'README.textile'
fileMode 0644
}
into('lib') {
with copySpec {
with libFiles
// we need to specify every intermediate directory so we iterate through the parents; duplicate calls with the same part are fine
eachFile { FileCopyDetails fcp ->
String[] segments = fcp.relativePath.segments
for (int i = segments.length - 2; i > 0 && segments[i] != 'lib'; --i) {
directory('/' + segments[0..i].join('/'), 0755)
}
fcp.mode = 0644
}
}
}
into('modules') {
with copySpec {
with modulesFiles(oss)
// we need to specify every intermediate directory, but modules could have sub directories
// and there might not be any files as direct children of intermediates (eg platform)
// so we must iterate through the parents, but duplicate calls with the same path
// are ok (they don't show up in the built packages)
// we need to specify every intermediate directory so we iterate through the parents; duplicate calls with the same part are fine
eachFile { FileCopyDetails fcp ->
String[] segments = fcp.relativePath.segments
for (int i = segments.length - 2; i > 0 && segments[i] != 'modules'; --i) {
@ -251,8 +261,8 @@ ospackage {
signingKeyId = project.hasProperty('signing.keyId') ? project.property('signing.keyId') : 'D88E42B4'
signingKeyPassphrase = project.property('signing.password')
signingKeyRingFile = project.hasProperty('signing.secretKeyRingFile') ?
project.file(project.property('signing.secretKeyRingFile')) :
new File(new File(System.getProperty('user.home'), '.gnupg'), 'secring.gpg')
project.file(project.property('signing.secretKeyRingFile')) :
new File(new File(System.getProperty('user.home'), '.gnupg'), 'secring.gpg')
}
requires('coreutils')
@ -263,7 +273,6 @@ ospackage {
permissionGroup 'root'
into '/usr/share/elasticsearch'
with libFiles
with noticeFile
}

View File

@ -10,6 +10,12 @@ do
source "`dirname "$0"`"/$additional_source
done
IFS=';' read -r -a additional_classpath_directories <<< "$ES_ADDITIONAL_CLASSPATH_DIRECTORIES"
for additional_classpath_directory in "${additional_classpath_directories[@]}"
do
ES_CLASSPATH="$ES_CLASSPATH:$ES_HOME/$additional_classpath_directory/*"
done
exec \
"$JAVA" \
$ES_JAVA_OPTS \

View File

@ -11,6 +11,12 @@ for /f "tokens=1*" %%a in ("%*") do (
set arguments=%%b
)
if defined ES_ADDITIONAL_CLASSPATH_DIRECTORIES (
for %%a in ("%ES_ADDITIONAL_CLASSPATH_DIRECTORIES:;=","%") do (
set ES_CLASSPATH=!ES_CLASSPATH!;!ES_HOME!/%%a/*
)
)
%JAVA% ^
%ES_JAVA_OPTS% ^
-Des.path.home="%ES_HOME%" ^

View File

@ -1,5 +1,6 @@
#!/bin/bash
"`dirname "$0"`"/elasticsearch-cli \
ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/plugin-cli \
"`dirname "$0"`"/elasticsearch-cli \
org.elasticsearch.plugins.PluginCli \
"$@"

View File

@ -3,6 +3,7 @@
setlocal enabledelayedexpansion
setlocal enableextensions
set ES_ADDITIONAL_CLASSPATH_DIRECTORIES=lib/tools/plugin-cli
call "%~dp0elasticsearch-cli.bat" ^
org.elasticsearch.plugins.PluginCli ^
%* ^

View File

@ -19,14 +19,22 @@
apply plugin: 'elasticsearch.build'
archivesBaseName = 'elasticsearch-plugin-cli'
dependencies {
compileOnly "org.elasticsearch:elasticsearch:${version}"
compileOnly "org.elasticsearch:elasticsearch-cli:${version}"
compile "org.bouncycastle:bcpg-jdk15on:1.59"
compile "org.bouncycastle:bcprov-jdk15on:1.59"
testCompile "org.elasticsearch.test:framework:${version}"
testCompile 'com.google.jimfs:jimfs:1.1'
testCompile 'com.google.guava:guava:18.0'
}
dependencyLicenses {
mapping from: /bc.*/, to: 'bouncycastle'
}
test {
// TODO: find a way to add permissions for the tests in this module
systemProperty 'tests.security.manager', 'false'

View File

@ -0,0 +1 @@
ee93e5376bb6cf0a15c027b5f5e4393f2738e709

View File

@ -0,0 +1 @@
2507204241ab450456bdb8e8c0a8f986e418bd99

View File

@ -0,0 +1,17 @@
Copyright (c) 2000-2015 The Legion of the Bouncy Castle Inc. (http://www.bouncycastle.org)
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.

View File

@ -23,6 +23,16 @@ import joptsimple.OptionSet;
import joptsimple.OptionSpec;
import org.apache.lucene.search.spell.LevensteinDistance;
import org.apache.lucene.util.CollectionUtil;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
import org.bouncycastle.openpgp.PGPSignature;
import org.bouncycastle.openpgp.PGPSignatureList;
import org.bouncycastle.openpgp.PGPUtil;
import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory;
import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider;
import org.elasticsearch.Build;
import org.elasticsearch.Version;
import org.elasticsearch.bootstrap.JarHell;
@ -43,6 +53,7 @@ import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLDecoder;
@ -59,8 +70,11 @@ import java.nio.file.attribute.PosixFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Security;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@ -116,7 +130,6 @@ class InstallPluginCommand extends EnvironmentAwareCommand {
/** The plugin zip is not properly structured. */
static final int PLUGIN_MALFORMED = 2;
/** The builtin modules, which are plugins, but cannot be installed or removed. */
static final Set<String> MODULES;
static {
@ -241,7 +254,7 @@ class InstallPluginCommand extends EnvironmentAwareCommand {
if (OFFICIAL_PLUGINS.contains(pluginId)) {
final String url = getElasticUrl(terminal, getStagingHash(), Version.CURRENT, isSnapshot(), pluginId, Platforms.PLATFORM_NAME);
terminal.println("-> Downloading " + pluginId + " from elastic");
return downloadZipAndChecksum(terminal, url, tmpDir, false);
return downloadAndValidate(terminal, url, tmpDir, true);
}
// now try as maven coordinates, a valid URL would only have a colon and slash
@ -249,7 +262,7 @@ class InstallPluginCommand extends EnvironmentAwareCommand {
if (coordinates.length == 3 && pluginId.contains("/") == false && pluginId.startsWith("file:") == false) {
String mavenUrl = getMavenUrl(terminal, coordinates, Platforms.PLATFORM_NAME);
terminal.println("-> Downloading " + pluginId + " from maven central");
return downloadZipAndChecksum(terminal, mavenUrl, tmpDir, true);
return downloadAndValidate(terminal, mavenUrl, tmpDir, false);
}
// fall back to plain old URL
@ -406,16 +419,44 @@ class InstallPluginCommand extends EnvironmentAwareCommand {
}
}
/** Downloads a zip from the url, as well as a SHA512 (or SHA1) checksum, and checks the checksum. */
// pkg private for tests
@SuppressForbidden(reason = "We use openStream to download plugins")
private Path downloadZipAndChecksum(Terminal terminal, String urlString, Path tmpDir, boolean allowSha1) throws Exception {
@SuppressForbidden(reason = "URL#openStream")
private InputStream urlOpenStream(final URL url) throws IOException {
return url.openStream();
}
/**
* Downloads a ZIP from the URL. This method also validates the downloaded plugin ZIP via the following means:
* <ul>
* <li>
* For an official plugin we download the SHA-512 checksum and validate the integrity of the downloaded ZIP. We also download the
* armored signature and validate the authenticity of the downloaded ZIP.
* </li>
* <li>
* For a non-official plugin we download the SHA-512 checksum and fallback to the SHA-1 checksum and validate the integrity of the
* downloaded ZIP.
* </li>
* </ul>
*
* @param terminal a terminal to log messages to
* @param urlString the URL of the plugin ZIP
* @param tmpDir a temporary directory to write downloaded files to
* @param officialPlugin true if the plugin is an official plugin
* @return the path to the downloaded plugin ZIP
* @throws IOException if an I/O exception occurs download or reading files and resources
* @throws PGPException if an exception occurs verifying the downloaded ZIP signature
* @throws UserException if checksum validation fails
*/
private Path downloadAndValidate(
final Terminal terminal,
final String urlString,
final Path tmpDir,
final boolean officialPlugin) throws IOException, PGPException, UserException {
Path zip = downloadZip(terminal, urlString, tmpDir);
pathsToDeleteOnShutdown.add(zip);
String checksumUrlString = urlString + ".sha512";
URL checksumUrl = openUrl(checksumUrlString);
String digestAlgo = "SHA-512";
if (checksumUrl == null && allowSha1) {
if (checksumUrl == null && officialPlugin == false) {
// fallback to sha1, until 7.0, but with warning
terminal.println("Warning: sha512 not found, falling back to sha1. This behavior is deprecated and will be removed in a " +
"future release. Please update the plugin to use a sha512 checksum.");
@ -427,7 +468,7 @@ class InstallPluginCommand extends EnvironmentAwareCommand {
throw new UserException(ExitCodes.IO_ERROR, "Plugin checksum missing: " + checksumUrlString);
}
final String expectedChecksum;
try (InputStream in = checksumUrl.openStream()) {
try (InputStream in = urlOpenStream(checksumUrl)) {
/*
* The supported format of the SHA-1 files is a single-line file containing the SHA-1. The supported format of the SHA-512 files
* is a single-line file containing the SHA-512 and the filename, separated by two spaces. For SHA-1, we verify that the hash
@ -465,23 +506,107 @@ class InstallPluginCommand extends EnvironmentAwareCommand {
}
}
byte[] zipbytes = Files.readAllBytes(zip);
String gotChecksum = MessageDigests.toHexString(MessageDigest.getInstance(digestAlgo).digest(zipbytes));
if (expectedChecksum.equals(gotChecksum) == false) {
throw new UserException(ExitCodes.IO_ERROR,
digestAlgo + " mismatch, expected " + expectedChecksum + " but got " + gotChecksum);
try {
final byte[] zipBytes = Files.readAllBytes(zip);
final String actualChecksum = MessageDigests.toHexString(MessageDigest.getInstance(digestAlgo).digest(zipBytes));
if (expectedChecksum.equals(actualChecksum) == false) {
throw new UserException(
ExitCodes.IO_ERROR,
digestAlgo + " mismatch, expected " + expectedChecksum + " but got " + actualChecksum);
}
} catch (final NoSuchAlgorithmException e) {
// this should never happen as we are using SHA-1 and SHA-512 here
throw new AssertionError(e);
}
if (officialPlugin) {
verifySignature(zip, urlString);
}
return zip;
}
/**
* Verify the signature of the downloaded plugin ZIP. The signature is obtained from the source of the downloaded plugin by appending
* ".asc" to the URL. It is expected that the plugin is signed with the Elastic signing key with ID D27D666CD88E42B4.
*
* @param zip the path to the downloaded plugin ZIP
* @param urlString the URL source of the downloade plugin ZIP
* @throws IOException if an I/O exception occurs reading from various input streams
* @throws PGPException if the PGP implementation throws an internal exception during verification
*/
void verifySignature(final Path zip, final String urlString) throws IOException, PGPException {
final String ascUrlString = urlString + ".asc";
final URL ascUrl = openUrl(ascUrlString);
try (
// fin is a file stream over the downloaded plugin zip whose signature to verify
InputStream fin = pluginZipInputStream(zip);
// sin is a URL stream to the signature corresponding to the downloaded plugin zip
InputStream sin = urlOpenStream(ascUrl);
// pin is a decoded base64 stream over the embedded public key in RFC2045 format
InputStream pin = Base64.getMimeDecoder().wrap(getPublicKey())) {
final JcaPGPObjectFactory factory = new JcaPGPObjectFactory(PGPUtil.getDecoderStream(sin));
final PGPSignature signature = ((PGPSignatureList) factory.nextObject()).get(0);
// validate the signature has key ID matching our public key ID
final String keyId = Long.toHexString(signature.getKeyID()).toUpperCase(Locale.ROOT);
if (getPublicKeyId().equals(keyId) == false) {
throw new IllegalStateException("key id [" + keyId + "] does not match expected key id [" + getPublicKeyId() + "]");
}
// compute the signature of the downloaded plugin zip
final PGPPublicKeyRingCollection collection = new PGPPublicKeyRingCollection(pin, new JcaKeyFingerprintCalculator());
final PGPPublicKey key = collection.getPublicKey(signature.getKeyID());
signature.init(new JcaPGPContentVerifierBuilderProvider().setProvider(new BouncyCastleProvider()), key);
final byte[] buffer = new byte[1024];
int read;
while ((read = fin.read(buffer)) != -1) {
signature.update(buffer, 0, read);
}
// finally we verify the signature of the downloaded plugin zip matches the expected signature
if (signature.verify() == false) {
throw new IllegalStateException("signature verification for [" + urlString + "] failed");
}
}
}
/**
* An input stream to the raw bytes of the plugin ZIP.
*
* @param zip the path to the downloaded plugin ZIP
* @return an input stream to the raw bytes of the plugin ZIP.
* @throws IOException if an I/O exception occurs preparing the input stream
*/
InputStream pluginZipInputStream(final Path zip) throws IOException {
return Files.newInputStream(zip);
}
/**
* Return the public key ID of the signing key that is expected to have signed the official plugin.
*
* @return the public key ID
*/
String getPublicKeyId() {
return "D27D666CD88E42B4";
}
/**
* An input stream to the public key of the signing key.
*
* @return an input stream to the public key
*/
InputStream getPublicKey() {
return InstallPluginCommand.class.getResourceAsStream("/public_key");
}
/**
* Creates a URL and opens a connection.
*
* If the URL returns a 404, {@code null} is returned, otherwise the open URL opject is returned.
*/
// pkg private for tests
URL openUrl(String urlString) throws Exception {
URL openUrl(String urlString) throws IOException {
URL checksumUrl = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection)checksumUrl.openConnection();
if (connection.getResponseCode() == 404) {
@ -605,11 +730,27 @@ class InstallPluginCommand extends EnvironmentAwareCommand {
return info;
}
private static final String LIB_TOOLS_PLUGIN_CLI_CLASSPATH_JAR;
static {
LIB_TOOLS_PLUGIN_CLI_CLASSPATH_JAR =
String.format(Locale.ROOT, ".+%1$slib%1$stools%1$splugin-cli%1$s[^%1$s]+\\.jar", "(/|\\\\)");
}
/** check a candidate plugin for jar hell before installing it */
void jarHellCheck(PluginInfo candidateInfo, Path candidateDir, Path pluginsDir, Path modulesDir) throws Exception {
// create list of current jars in classpath
final Set<URL> jars = new HashSet<>(JarHell.parseClassPath());
final Set<URL> classpath =
JarHell.parseClassPath()
.stream()
.filter(url -> {
try {
return url.toURI().getPath().matches(LIB_TOOLS_PLUGIN_CLI_CLASSPATH_JAR) == false;
} catch (final URISyntaxException e) {
throw new AssertionError(e);
}
})
.collect(Collectors.toSet());
// read existing bundles. this does some checks on the installation too.
Set<PluginsService.Bundle> bundles = new HashSet<>(PluginsService.getPluginBundles(pluginsDir));
@ -621,7 +762,7 @@ class InstallPluginCommand extends EnvironmentAwareCommand {
// TODO: optimize to skip any bundles not connected to the candidate plugin?
Map<String, Set<URL>> transitiveUrls = new HashMap<>();
for (PluginsService.Bundle bundle : sortedBundles) {
PluginsService.checkBundleJarHell(bundle, transitiveUrls);
PluginsService.checkBundleJarHell(classpath, bundle, transitiveUrls);
}
// TODO: no jars should be an error

View File

@ -0,0 +1,24 @@
mQENBFI3HsoBCADXDtbNJnxbPqB1vDNtCsqhe49vFYsZN9IOZsZXgp7aHjh6CJBDA+bGFOwy
hbd7at35jQjWAw1O3cfYsKAmFy+Ar3LHCMkV3oZspJACTIgCrwnkic/9CUliQe324qvObU2Q
RtP4Fl0zWcfb/S8UYzWXWIFuJqMvE9MaRY1bwUBvzoqavLGZj3SF1SPO+TB5QrHkrQHBsmX+
Jda6d4Ylt8/t6CvMwgQNlrlzIO9WT+YN6zS+sqHd1YK/aY5qhoLNhp9G/HxhcSVCkLq8SStj
1ZZ1S9juBPoXV1ZWNbxFNGwOh/NYGldD2kmBf3YgCqeLzHahsAEpvAm8TBa7Q9W21C8vABEB
AAG0RUVsYXN0aWNzZWFyY2ggKEVsYXN0aWNzZWFyY2ggU2lnbmluZyBLZXkpIDxkZXZfb3Bz
QGVsYXN0aWNzZWFyY2gub3JnPokBOAQTAQIAIgUCUjceygIbAwYLCQgHAwIGFQgCCQoLBBYC
AwECHgECF4AACgkQ0n1mbNiOQrRzjAgAlTUQ1mgo3nK6BGXbj4XAJvuZDG0HILiUt+pPnz75
nsf0NWhqR4yGFlmpuctgCmTD+HzYtV9fp9qW/bwVuJCNtKXk3sdzYABY+Yl0Cez/7C2GuGCO
lbn0luCNT9BxJnh4mC9h/cKI3y5jvZ7wavwe41teqG14V+EoFSn3NPKmTxcDTFrV7SmVPxCB
cQze00cJhprKxkuZMPPVqpBS+JfDQtzUQD/LSFfhHj9eD+Xe8d7sw+XvxB2aN4gnTlRzjL1n
TRp0h2/IOGkqYfIG9rWmSLNlxhB2t+c0RsjdGM4/eRlPWylFbVMc5pmDpItrkWSnzBfkmXL3
vO2X3WvwmSFiQbkBDQRSNx7KAQgA5JUlzcMW5/cuyZR8alSacKqhSbvoSqqbzHKcUQZmlzNM
KGTABFG1yRx9r+wa/fvqP6OTRzRDvVS/cycws8YX7Ddum7x8uI95b9ye1/Xy5noPEm8cD+hp
lnpU+PBQZJ5XJ2I+1l9Nixx47wPGXeClLqcdn0ayd+v+Rwf3/XUJrvccG2YZUiQ4jWZkoxsA
07xx7Bj+Lt8/FKG7sHRFvePFU0ZS6JFx9GJqjSBbHRRkam+4emW3uWgVfZxuwcUCn1ayNgRt
KiFv9jQrg2TIWEvzYx9tywTCxc+FFMWAlbCzi+m4WD+QUWWfDQ009U/WM0ks0KwwEwSk/UDu
ToxGnKU2dQARAQABiQEfBBgBAgAJBQJSNx7KAhsMAAoJENJ9ZmzYjkK0c3MIAIE9hAR20mqJ
WLcsxLtrRs6uNF1VrpB+4n/55QU7oxA1iVBO6IFu4qgsF12JTavnJ5MLaETlggXY+zDef9sy
TPXoQctpzcaNVDmedwo1SiL03uMoblOvWpMR/Y0j6rm7IgrMWUDXDPvoPGjMl2q1iTeyHkMZ
EyUJ8SKsaHh4jV9wp9KmC8C+9CwMukL7vM5w8cgvJoAwsp3Fn59AxWthN3XJYcnMfStkIuWg
R7U2r+a210W6vnUxU4oN0PmMcursYPyeV0NX/KQeUeNMwGTFB6QHS/anRaGQewijkrYYoTNt
fllxIu9XYmiBERQ/qPDlGRlOgVTd9xUfHFkzB52c70E=
=92oX

View File

@ -23,6 +23,25 @@ import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
import com.google.common.jimfs.Configuration;
import com.google.common.jimfs.Jimfs;
import org.apache.lucene.util.LuceneTestCase;
import org.bouncycastle.bcpg.ArmoredOutputStream;
import org.bouncycastle.bcpg.BCPGOutputStream;
import org.bouncycastle.bcpg.HashAlgorithmTags;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openpgp.PGPEncryptedData;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPKeyPair;
import org.bouncycastle.openpgp.PGPPrivateKey;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPSecretKey;
import org.bouncycastle.openpgp.PGPSignature;
import org.bouncycastle.openpgp.PGPSignatureGenerator;
import org.bouncycastle.openpgp.operator.PGPDigestCalculator;
import org.bouncycastle.openpgp.operator.bc.BcPBESecretKeyDecryptorBuilder;
import org.bouncycastle.openpgp.operator.bc.BcPGPContentSignerBuilder;
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder;
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder;
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPKeyPair;
import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyEncryptorBuilder;
import org.elasticsearch.Build;
import org.elasticsearch.Version;
import org.elasticsearch.cli.ExitCodes;
@ -44,6 +63,8 @@ import org.junit.After;
import org.junit.Before;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
@ -66,13 +87,19 @@ import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFileAttributes;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.UserPrincipal;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -800,8 +827,16 @@ public class InstallPluginCommandTests extends ESTestCase {
skipJarHellCommand.execute(terminal, pluginZip, isBatch, env.v2());
}
void assertInstallPluginFromUrl(String pluginId, String name, String url, String stagingHash, boolean isSnapshot,
String shaExtension, Function<byte[], String> shaCalculator) throws Exception {
void assertInstallPluginFromUrl(
final String pluginId,
final String name,
final String url,
final String stagingHash,
final boolean isSnapshot,
final String shaExtension,
final Function<byte[], String> shaCalculator,
final PGPSecretKey secretKey,
final BiFunction<byte[], PGPSecretKey, String> signature) throws Exception {
Tuple<Path, Environment> env = createEnv(fs, temp);
Path pluginDir = createPluginDir(temp);
Path pluginZip = createPlugin(name, pluginDir);
@ -814,18 +849,61 @@ public class InstallPluginCommandTests extends ESTestCase {
return downloadedPath;
}
@Override
URL openUrl(String urlString) throws Exception {
String expectedUrl = url + shaExtension;
if (expectedUrl.equals(urlString)) {
URL openUrl(String urlString) throws IOException {
if ((url + shaExtension).equals(urlString)) {
// calc sha an return file URL to it
Path shaFile = temp.apply("shas").resolve("downloaded.zip" + shaExtension);
byte[] zipbytes = Files.readAllBytes(pluginZip);
String checksum = shaCalculator.apply(zipbytes);
Files.write(shaFile, checksum.getBytes(StandardCharsets.UTF_8));
return shaFile.toUri().toURL();
} else if ((url + ".asc").equals(urlString)) {
final Path ascFile = temp.apply("asc").resolve("downloaded.zip" + ".asc");
final byte[] zipBytes = Files.readAllBytes(pluginZip);
final String asc = signature.apply(zipBytes, secretKey);
Files.write(ascFile, asc.getBytes(StandardCharsets.UTF_8));
return ascFile.toUri().toURL();
}
return null;
}
@Override
void verifySignature(Path zip, String urlString) throws IOException, PGPException {
if (InstallPluginCommand.OFFICIAL_PLUGINS.contains(name)) {
super.verifySignature(zip, urlString);
} else {
throw new UnsupportedOperationException("verify signature should not be called for unofficial plugins");
}
}
@Override
InputStream pluginZipInputStream(Path zip) throws IOException {
return new ByteArrayInputStream(Files.readAllBytes(zip));
}
@Override
String getPublicKeyId() {
return Long.toHexString(secretKey.getKeyID()).toUpperCase(Locale.ROOT);
}
@Override
InputStream getPublicKey() {
try {
final ByteArrayOutputStream output = new ByteArrayOutputStream();
final ArmoredOutputStream armored = new ArmoredOutputStream(output);
secretKey.getPublicKey().encode(armored);
armored.close();
final String publicKey = new String(output.toByteArray(), "UTF-8");
int start = publicKey.indexOf("\n", 1 + publicKey.indexOf("\n"));
int end = publicKey.lastIndexOf("\n", publicKey.lastIndexOf("\n") - 1);
// strip the header (first two lines) and footer (last line)
final String substring = publicKey.substring(1 + start, end);
return new ByteArrayInputStream(substring.getBytes("UTF-8"));
} catch (final IOException e) {
throw new AssertionError(e);
}
}
@Override
boolean urlExists(Terminal terminal, String urlString) throws IOException {
return urlString.equals(url);
@ -851,11 +929,12 @@ public class InstallPluginCommandTests extends ESTestCase {
public void assertInstallPluginFromUrl(
final String pluginId, final String name, final String url, final String stagingHash, boolean isSnapshot) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-512");
assertInstallPluginFromUrl(pluginId, name, url, stagingHash, isSnapshot, ".sha512", checksumAndFilename(digest, url));
final MessageDigest digest = MessageDigest.getInstance("SHA-512");
assertInstallPluginFromUrl(
pluginId, name, url, stagingHash, isSnapshot, ".sha512", checksumAndFilename(digest, url), newSecretKey(), this::signature);
}
public void testOfficalPlugin() throws Exception {
public void testOfficialPlugin() throws Exception {
String url = "https://artifacts.elastic.co/downloads/elasticsearch-plugins/analysis-icu/analysis-icu-" + Version.CURRENT + ".zip";
assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, null, false);
}
@ -883,13 +962,13 @@ public class InstallPluginCommandTests extends ESTestCase {
e, hasToString(containsString("attempted to install release build of official plugin on snapshot build of Elasticsearch")));
}
public void testOfficalPluginStaging() throws Exception {
public void testOfficialPluginStaging() throws Exception {
String url = "https://staging.elastic.co/" + Version.CURRENT + "-abc123/downloads/elasticsearch-plugins/analysis-icu/analysis-icu-"
+ Version.CURRENT + ".zip";
assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, "abc123", false);
}
public void testOfficalPlatformPlugin() throws Exception {
public void testOfficialPlatformPlugin() throws Exception {
String url = "https://artifacts.elastic.co/downloads/elasticsearch-plugins/analysis-icu/analysis-icu-" + Platforms.PLATFORM_NAME +
"-" + Version.CURRENT + ".zip";
assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, null, false);
@ -905,7 +984,7 @@ public class InstallPluginCommandTests extends ESTestCase {
assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, "abc123", true);
}
public void testOfficalPlatformPluginStaging() throws Exception {
public void testOfficialPlatformPluginStaging() throws Exception {
String url = "https://staging.elastic.co/" + Version.CURRENT + "-abc123/downloads/elasticsearch-plugins/analysis-icu/analysis-icu-"
+ Platforms.PLATFORM_NAME + "-"+ Version.CURRENT + ".zip";
assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, "abc123", false);
@ -924,7 +1003,7 @@ public class InstallPluginCommandTests extends ESTestCase {
public void testMavenSha1Backcompat() throws Exception {
String url = "https://repo1.maven.org/maven2/mygroup/myplugin/1.0.0/myplugin-1.0.0.zip";
MessageDigest digest = MessageDigest.getInstance("SHA-1");
assertInstallPluginFromUrl("mygroup:myplugin:1.0.0", "myplugin", url, null, false, ".sha1", checksum(digest));
assertInstallPluginFromUrl("mygroup:myplugin:1.0.0", "myplugin", url, null, false, ".sha1", checksum(digest), null, (b, p) -> null);
assertTrue(terminal.getOutput(), terminal.getOutput().contains("sha512 not found, falling back to sha1"));
}
@ -932,7 +1011,7 @@ public class InstallPluginCommandTests extends ESTestCase {
String url = "https://artifacts.elastic.co/downloads/elasticsearch-plugins/analysis-icu/analysis-icu-" + Version.CURRENT + ".zip";
MessageDigest digest = MessageDigest.getInstance("SHA-1");
UserException e = expectThrows(UserException.class, () ->
assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, null, false, ".sha1", checksum(digest)));
assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, null, false, ".sha1", checksum(digest), null, (b, p) -> null));
assertEquals(ExitCodes.IO_ERROR, e.exitCode);
assertEquals("Plugin checksum missing: " + url + ".sha512", e.getMessage());
}
@ -940,7 +1019,8 @@ public class InstallPluginCommandTests extends ESTestCase {
public void testMavenShaMissing() throws Exception {
String url = "https://repo1.maven.org/maven2/mygroup/myplugin/1.0.0/myplugin-1.0.0.zip";
UserException e = expectThrows(UserException.class, () ->
assertInstallPluginFromUrl("mygroup:myplugin:1.0.0", "myplugin", url, null, false, ".dne", bytes -> null));
assertInstallPluginFromUrl(
"mygroup:myplugin:1.0.0", "myplugin", url, null, false, ".dne", bytes -> null, null, (b, p) -> null));
assertEquals(ExitCodes.IO_ERROR, e.exitCode);
assertEquals("Plugin checksum missing: " + url + ".sha1", e.getMessage());
}
@ -948,8 +1028,9 @@ public class InstallPluginCommandTests extends ESTestCase {
public void testInvalidShaFileMissingFilename() throws Exception {
String url = "https://artifacts.elastic.co/downloads/elasticsearch-plugins/analysis-icu/analysis-icu-" + Version.CURRENT + ".zip";
MessageDigest digest = MessageDigest.getInstance("SHA-512");
UserException e = expectThrows(UserException.class, () ->
assertInstallPluginFromUrl("analysis-icu", "analysis-icu", url, null, false, ".sha512", checksum(digest)));
UserException e = expectThrows(UserException.class,
() -> assertInstallPluginFromUrl(
"analysis-icu", "analysis-icu", url, null, false, ".sha512", checksum(digest), null, (b, p) -> null));
assertEquals(ExitCodes.IO_ERROR, e.exitCode);
assertTrue(e.getMessage(), e.getMessage().startsWith("Invalid checksum file"));
}
@ -965,7 +1046,9 @@ public class InstallPluginCommandTests extends ESTestCase {
null,
false,
".sha512",
checksumAndString(digest, " repository-s3-" + Version.CURRENT + ".zip")));
checksumAndString(digest, " repository-s3-" + Version.CURRENT + ".zip"),
null,
(b, p) -> null));
assertEquals(ExitCodes.IO_ERROR, e.exitCode);
assertThat(e, hasToString(matches("checksum file at \\[.*\\] is not for this plugin")));
}
@ -981,7 +1064,9 @@ public class InstallPluginCommandTests extends ESTestCase {
null,
false,
".sha512",
checksumAndString(digest, " analysis-icu-" + Version.CURRENT + ".zip\nfoobar")));
checksumAndString(digest, " analysis-icu-" + Version.CURRENT + ".zip\nfoobar"),
null,
(b, p) -> null));
assertEquals(ExitCodes.IO_ERROR, e.exitCode);
assertTrue(e.getMessage(), e.getMessage().startsWith("Invalid checksum file"));
}
@ -996,7 +1081,9 @@ public class InstallPluginCommandTests extends ESTestCase {
null,
false,
".sha512",
bytes -> "foobar analysis-icu-" + Version.CURRENT + ".zip"));
bytes -> "foobar analysis-icu-" + Version.CURRENT + ".zip",
null,
(b, p) -> null));
assertEquals(ExitCodes.IO_ERROR, e.exitCode);
assertTrue(e.getMessage(), e.getMessage().contains("SHA-512 mismatch, expected foobar"));
}
@ -1004,11 +1091,77 @@ public class InstallPluginCommandTests extends ESTestCase {
public void testSha1Mismatch() throws Exception {
String url = "https://repo1.maven.org/maven2/mygroup/myplugin/1.0.0/myplugin-1.0.0.zip";
UserException e = expectThrows(UserException.class, () ->
assertInstallPluginFromUrl("mygroup:myplugin:1.0.0", "myplugin", url, null, false, ".sha1", bytes -> "foobar"));
assertInstallPluginFromUrl(
"mygroup:myplugin:1.0.0", "myplugin", url, null, false, ".sha1", bytes -> "foobar", null, (b, p) -> null));
assertEquals(ExitCodes.IO_ERROR, e.exitCode);
assertTrue(e.getMessage(), e.getMessage().contains("SHA-1 mismatch, expected foobar"));
}
public void testPublicKeyIdMismatchToExpectedPublicKeyId() throws Exception {
final String icu = "analysis-icu";
final String url =
"https://artifacts.elastic.co/downloads/elasticsearch-plugins/analysis-icu/" + icu + "-" + Version.CURRENT + ".zip";
final MessageDigest digest = MessageDigest.getInstance("SHA-512");
/*
* To setup a situation where the expected public key ID does not match the public key ID used for signing, we generate a new public
* key at the moment of signing (see the signature invocation). Note that this key will not match the key that we push down to the
* install plugin command.
*/
final PGPSecretKey signingKey = newSecretKey(); // the actual key used for signing
final String actualID = Long.toHexString(signingKey.getKeyID()).toUpperCase(Locale.ROOT);
final BiFunction<byte[], PGPSecretKey, String> signature = (b, p) -> signature(b, signingKey);
final PGPSecretKey verifyingKey = newSecretKey(); // the expected key used for signing
final String expectedID = Long.toHexString(verifyingKey.getKeyID()).toUpperCase(Locale.ROOT);
final IllegalStateException e = expectThrows(
IllegalStateException.class,
() ->
assertInstallPluginFromUrl(
icu, icu, url, null, false, ".sha512", checksumAndFilename(digest, url), verifyingKey, signature));
assertThat(e, hasToString(containsString("key id [" + actualID + "] does not match expected key id [" + expectedID + "]")));
}
public void testFailedSignatureVerification() throws Exception {
final String icu = "analysis-icu";
final String url =
"https://artifacts.elastic.co/downloads/elasticsearch-plugins/analysis-icu/" + icu + "-" + Version.CURRENT + ".zip";
final MessageDigest digest = MessageDigest.getInstance("SHA-512");
/*
* To setup a situation where signature verification fails, we will mutate the input byte array by modifying a single byte to some
* random byte value other than the actual value. This is enough to change the signature and cause verification to intentionally
* fail.
*/
final BiFunction<byte[], PGPSecretKey, String> signature = (b, p) -> {
final byte[] bytes = Arrays.copyOf(b, b.length);
bytes[0] = randomValueOtherThan(b[0], ESTestCase::randomByte);
return signature(bytes, p);
};
final IllegalStateException e = expectThrows(
IllegalStateException.class,
() ->
assertInstallPluginFromUrl(
icu, icu, url, null, false, ".sha512", checksumAndFilename(digest, url), newSecretKey(), signature));
assertThat(e, hasToString(equalTo("java.lang.IllegalStateException: signature verification for [" + url + "] failed")));
}
public PGPSecretKey newSecretKey() throws NoSuchAlgorithmException, NoSuchProviderException, PGPException {
final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
final KeyPair pair = kpg.generateKeyPair();
final PGPDigestCalculator sha1Calc = new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA1);
final PGPKeyPair pkp = new JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, pair, new Date());
return new PGPSecretKey(
PGPSignature.DEFAULT_CERTIFICATION,
pkp,
"example@example.com",
sha1Calc,
null,
null,
new JcaPGPContentSignerBuilder(pkp.getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA1),
new JcePBESecretKeyEncryptorBuilder(PGPEncryptedData.CAST5, sha1Calc)
.setProvider(new BouncyCastleProvider())
.build("passphrase".toCharArray()));
}
private Function<byte[], String> checksum(final MessageDigest digest) {
return checksumAndString(digest, "");
}
@ -1022,6 +1175,32 @@ public class InstallPluginCommandTests extends ESTestCase {
return bytes -> MessageDigests.toHexString(digest.digest(bytes)) + s;
}
private String signature(final byte[] bytes, final PGPSecretKey secretKey) {
try {
final PGPPrivateKey privateKey
= secretKey.extractPrivateKey(
new BcPBESecretKeyDecryptorBuilder(
new JcaPGPDigestCalculatorProviderBuilder().build()).build("passphrase".toCharArray()));
final PGPSignatureGenerator generator =
new PGPSignatureGenerator(
new BcPGPContentSignerBuilder(privateKey.getPublicKeyPacket().getAlgorithm(), HashAlgorithmTags.SHA512));
generator.init(PGPSignature.BINARY_DOCUMENT, privateKey);
final ByteArrayOutputStream output = new ByteArrayOutputStream();
try (BCPGOutputStream pout = new BCPGOutputStream(new ArmoredOutputStream(output));
InputStream is = new ByteArrayInputStream(bytes)) {
final byte[] buffer = new byte[1024];
int read;
while ((read = is.read(buffer)) != -1) {
generator.update(buffer, 0, read);
}
generator.generate().encode(pout);
}
return new String(output.toByteArray(), "UTF-8");
} catch (IOException | PGPException e) {
throw new RuntimeException(e);
}
}
// checks the plugin requires a policy confirmation, and does not install when that is rejected by the user
// the plugin is installed after this method completes
private void assertPolicyConfirmation(Tuple<Path, Environment> env, String pluginZip, String... warnings) throws Exception {

View File

@ -440,7 +440,7 @@ public class PluginsService extends AbstractComponent {
List<Bundle> sortedBundles = sortBundles(bundles);
for (Bundle bundle : sortedBundles) {
checkBundleJarHell(bundle, transitiveUrls);
checkBundleJarHell(JarHell.parseClassPath(), bundle, transitiveUrls);
final Plugin plugin = loadBundle(bundle, loaded);
plugins.add(new Tuple<>(bundle.plugin, plugin));
@ -451,7 +451,7 @@ public class PluginsService extends AbstractComponent {
// jar-hell check the bundle against the parent classloader and extended plugins
// the plugin cli does it, but we do it again, in case lusers mess with jar files manually
static void checkBundleJarHell(Bundle bundle, Map<String, Set<URL>> transitiveUrls) {
static void checkBundleJarHell(Set<URL> classpath, Bundle bundle, Map<String, Set<URL>> transitiveUrls) {
// invariant: any plugins this plugin bundle extends have already been added to transitiveUrls
List<String> exts = bundle.plugin.getExtendedPlugins();
@ -484,7 +484,6 @@ public class PluginsService extends AbstractComponent {
JarHell.checkJarHell(urls, logger::debug); // check jarhell of each extended plugin against this plugin
transitiveUrls.put(bundle.plugin.getName(), urls);
Set<URL> classpath = JarHell.parseClassPath();
// check we don't have conflicting codebases with core
Set<URL> intersection = new HashSet<>(classpath);
intersection.retainAll(bundle.urls);

View File

@ -23,6 +23,7 @@ import org.apache.log4j.Level;
import org.apache.lucene.util.Constants;
import org.apache.lucene.util.LuceneTestCase;
import org.elasticsearch.Version;
import org.elasticsearch.bootstrap.JarHell;
import org.elasticsearch.common.io.PathUtils;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.env.Environment;
@ -443,7 +444,7 @@ public class PluginsServiceTests extends ESTestCase {
"MyPlugin", Collections.singletonList("dep"), false);
PluginsService.Bundle bundle = new PluginsService.Bundle(info1, pluginDir);
IllegalStateException e = expectThrows(IllegalStateException.class, () ->
PluginsService.checkBundleJarHell(bundle, transitiveDeps));
PluginsService.checkBundleJarHell(JarHell.parseClassPath(), bundle, transitiveDeps));
assertEquals("failed to load plugin myplugin due to jar hell", e.getMessage());
assertThat(e.getCause().getMessage(), containsString("jar hell! duplicate codebases with extended plugin"));
}
@ -462,7 +463,7 @@ public class PluginsServiceTests extends ESTestCase {
"MyPlugin", Arrays.asList("dep1", "dep2"), false);
PluginsService.Bundle bundle = new PluginsService.Bundle(info1, pluginDir);
IllegalStateException e = expectThrows(IllegalStateException.class, () ->
PluginsService.checkBundleJarHell(bundle, transitiveDeps));
PluginsService.checkBundleJarHell(JarHell.parseClassPath(), bundle, transitiveDeps));
assertEquals("failed to load plugin myplugin due to jar hell", e.getMessage());
assertThat(e.getCause().getMessage(), containsString("jar hell!"));
assertThat(e.getCause().getMessage(), containsString("duplicate codebases"));
@ -479,7 +480,7 @@ public class PluginsServiceTests extends ESTestCase {
"MyPlugin", Collections.emptyList(), false);
PluginsService.Bundle bundle = new PluginsService.Bundle(info1, pluginDir);
IllegalStateException e = expectThrows(IllegalStateException.class, () ->
PluginsService.checkBundleJarHell(bundle, new HashMap<>()));
PluginsService.checkBundleJarHell(JarHell.parseClassPath(), bundle, new HashMap<>()));
assertEquals("failed to load plugin myplugin due to jar hell", e.getMessage());
assertThat(e.getCause().getMessage(), containsString("jar hell!"));
assertThat(e.getCause().getMessage(), containsString("Level"));
@ -498,7 +499,7 @@ public class PluginsServiceTests extends ESTestCase {
"MyPlugin", Collections.singletonList("dep"), false);
PluginsService.Bundle bundle = new PluginsService.Bundle(info1, pluginDir);
IllegalStateException e = expectThrows(IllegalStateException.class, () ->
PluginsService.checkBundleJarHell(bundle, transitiveDeps));
PluginsService.checkBundleJarHell(JarHell.parseClassPath(), bundle, transitiveDeps));
assertEquals("failed to load plugin myplugin due to jar hell", e.getMessage());
assertThat(e.getCause().getMessage(), containsString("jar hell!"));
assertThat(e.getCause().getMessage(), containsString("DummyClass1"));
@ -521,7 +522,7 @@ public class PluginsServiceTests extends ESTestCase {
"MyPlugin", Arrays.asList("dep1", "dep2"), false);
PluginsService.Bundle bundle = new PluginsService.Bundle(info1, pluginDir);
IllegalStateException e = expectThrows(IllegalStateException.class, () ->
PluginsService.checkBundleJarHell(bundle, transitiveDeps));
PluginsService.checkBundleJarHell(JarHell.parseClassPath(), bundle, transitiveDeps));
assertEquals("failed to load plugin myplugin due to jar hell", e.getMessage());
assertThat(e.getCause().getMessage(), containsString("jar hell!"));
assertThat(e.getCause().getMessage(), containsString("DummyClass2"));
@ -543,7 +544,7 @@ public class PluginsServiceTests extends ESTestCase {
PluginInfo info1 = new PluginInfo("myplugin", "desc", "1.0", Version.CURRENT, "1.8",
"MyPlugin", Arrays.asList("dep1", "dep2"), false);
PluginsService.Bundle bundle = new PluginsService.Bundle(info1, pluginDir);
PluginsService.checkBundleJarHell(bundle, transitiveDeps);
PluginsService.checkBundleJarHell(JarHell.parseClassPath(), bundle, transitiveDeps);
Set<URL> deps = transitiveDeps.get("myplugin");
assertNotNull(deps);
assertThat(deps, containsInAnyOrder(pluginJar.toUri().toURL(), dep1Jar.toUri().toURL(), dep2Jar.toUri().toURL()));