Add security notifications to inform about vulnerabilities (#1924)

Add security notifications in SCM-Manager to inform running instances about known security issues. These alerts can be core or plugin specific and will be shown to every user in the header.

Co-authored-by: Matthias Thieroff <matthias.thieroff@cloudogu.com>
Co-authored-by: Philipp Ahrendt <philipp.ahrendt@cloudogu.com>
Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2022-01-19 11:58:55 +01:00
committed by GitHub
parent 07fa753f80
commit 63ec4e6172
42 changed files with 1379 additions and 420 deletions

View File

@@ -0,0 +1,254 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* 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 sonia.scm.api.v2.resources;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import sonia.scm.api.v2.resources.AlertsResource.AlertsRequest;
import sonia.scm.api.v2.resources.AlertsResource.AlertsRequestBody;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.plugin.InstalledPlugin;
import sonia.scm.plugin.InstalledPluginDescriptor;
import sonia.scm.plugin.PluginInformation;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.util.SystemUtil;
import sonia.scm.web.RestDispatcher;
import sonia.scm.web.VndMediaType;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AlertsResourceTest {
private final ObjectMapper mapper = new ObjectMapper();
@Mock
private PluginLoader pluginLoader;
@Mock
private SCMContextProvider scmContextProvider;
private RestDispatcher restDispatcher;
private ScmConfiguration scmConfiguration;
@BeforeEach
void setUp() {
restDispatcher = new RestDispatcher();
scmConfiguration = new ScmConfiguration();
}
@Test
void shouldFailWithConflictIfAlertsUrlIsNull() throws Exception {
scmConfiguration.setAlertsUrl(null);
MockHttpResponse response = invoke();
assertThat(response.getStatus()).isEqualTo(409);
}
@Test
void shouldReturnSelfUrl() throws Exception {
MockHttpResponse response = invoke();
JsonNode node = mapper.readTree(response.getContentAsString());
assertThat(node.get("_links").get("self").get("href").asText()).isEqualTo("/v2/alerts");
}
@Test
void shouldReturnAlertsUrl() throws Exception {
MockHttpResponse response = invoke();
JsonNode node = mapper.readTree(response.getContentAsString());
assertThat(node.get("_links").get("alerts").get("href").asText()).isEqualTo(ScmConfiguration.DEFAULT_ALERTS_URL);
}
@Test
void shouldReturnVndMediaType() throws Exception {
MockHttpResponse response = invoke();
assertThat(response.getOutputHeaders().getFirst("Content-Type")).hasToString(VndMediaType.ALERTS_REQUEST);
}
@Test
void shouldReturnCustomAlertsUrl() throws Exception {
scmConfiguration.setAlertsUrl("https://mycustom.alerts.io");
MockHttpResponse response = invoke();
JsonNode node = mapper.readTree(response.getContentAsString());
assertThat(node.get("_links").get("alerts").get("href").asText()).isEqualTo("https://mycustom.alerts.io");
}
@Test
void shouldReturnAlertsRequest() throws Exception {
String instanceId = UUID.randomUUID().toString();
when(scmContextProvider.getInstanceId()).thenReturn(instanceId);
when(scmContextProvider.getVersion()).thenReturn("2.28.0");
InstalledPlugin pluginA = createInstalledPlugin("some-scm-plugin", "1.0.0");
InstalledPlugin pluginB = createInstalledPlugin("other-scm-plugin", "2.1.1");
when(pluginLoader.getInstalledPlugins()).thenReturn(Arrays.asList(pluginA, pluginB));
MockHttpResponse response = invoke();
assertThat(response.getStatus()).isEqualTo(200);
AlertsRequest alertsRequest = unmarshal(response);
AlertsRequestBody body = alertsRequest.getBody();
assertThat(body.getInstanceId()).isEqualTo(instanceId);
assertThat(body.getVersion()).isEqualTo("2.28.0");
assertThat(body.getOs()).isEqualTo(SystemUtil.getOS());
assertThat(body.getArch()).isEqualTo(SystemUtil.getArch());
assertThat(body.getJre()).isEqualTo(SystemUtil.getJre());
List<AlertsResource.Plugin> plugins = body.getPlugins();
assertThat(plugins.size()).isEqualTo(2);
AlertsResource.Plugin somePlugin = findPlugin(plugins, "some-scm-plugin");
assertThat(somePlugin.getVersion()).isEqualTo("1.0.0");
AlertsResource.Plugin otherPlugin = findPlugin(plugins, "other-scm-plugin");
assertThat(otherPlugin.getVersion()).isEqualTo("2.1.1");
}
@Test
void shouldReturnSameChecksumIfNothingChanged() throws Exception {
String instanceId = UUID.randomUUID().toString();
when(scmContextProvider.getInstanceId()).thenReturn(instanceId);
when(scmContextProvider.getVersion()).thenReturn("2.28.0");
InstalledPlugin plugin = createInstalledPlugin("some-scm-plugin", "1.0.0");
when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(plugin));
MockHttpResponse response = invoke();
String checksum = unmarshal(response).getChecksum();
MockHttpResponse secondResponse = invoke();
assertThat(unmarshal(secondResponse).getChecksum()).isEqualTo(checksum);
}
@Test
void shouldReturnDifferentChecksumIfCoreVersionChanges() throws Exception {
when(scmContextProvider.getVersion()).thenReturn("2.28.0");
MockHttpResponse response = invoke();
String checksum = unmarshal(response).getChecksum();
when(scmContextProvider.getVersion()).thenReturn("2.28.1");
MockHttpResponse secondResponse = invoke();
assertThat(unmarshal(secondResponse).getChecksum()).isNotEqualTo(checksum);
}
@Test
void shouldReturnDifferentChecksumIfPluginVersionChanges() throws Exception {
InstalledPlugin plugin1_0_0 = createInstalledPlugin("some-scm-plugin", "1.0.0");
when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(plugin1_0_0));
MockHttpResponse response = invoke();
String checksum = unmarshal(response).getChecksum();
InstalledPlugin plugin1_0_1 = createInstalledPlugin("some-scm-plugin", "1.0.1");
when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(plugin1_0_1));
MockHttpResponse secondResponse = invoke();
assertThat(unmarshal(secondResponse).getChecksum()).isNotEqualTo(checksum);
}
@Test
void shouldReturnDifferentChecksumIfDateChanges() throws Exception {
MockHttpResponse response = invoke();
String checksum = unmarshal(response).getChecksum();
InstalledPlugin plugin1_0_1 = createInstalledPlugin("some-scm-plugin", "1.0.1");
when(pluginLoader.getInstalledPlugins()).thenReturn(Collections.singletonList(plugin1_0_1));
MockHttpResponse secondResponse = invoke("1979-10-12");
assertThat(unmarshal(secondResponse).getChecksum()).isNotEqualTo(checksum);
}
private InstalledPlugin createInstalledPlugin(String name, String version) {
PluginInformation pluginInformation = new PluginInformation();
pluginInformation.setName(name);
pluginInformation.setVersion(version);
return createInstalledPlugin(pluginInformation);
}
private InstalledPlugin createInstalledPlugin(PluginInformation pluginInformation) {
InstalledPluginDescriptor descriptor = mock(InstalledPluginDescriptor.class);
lenient().when(descriptor.getInformation()).thenReturn(pluginInformation);
return new InstalledPlugin(descriptor, null, null, null, false);
}
private MockHttpResponse invoke() throws Exception {
return invoke(null);
}
private MockHttpResponse invoke(String date) throws Exception {
AlertsResource alertsResource;
if (date != null) {
alertsResource = new AlertsResource(scmContextProvider, scmConfiguration, pluginLoader, () -> date);
} else {
alertsResource = new AlertsResource(scmContextProvider, scmConfiguration, pluginLoader);
}
restDispatcher.addSingletonResource(alertsResource);
MockHttpRequest request = MockHttpRequest.get("/v2/alerts");
MockHttpResponse response = new MockHttpResponse();
restDispatcher.invoke(request, response);
return response;
}
private AlertsRequest unmarshal(MockHttpResponse response) throws JsonProcessingException, UnsupportedEncodingException {
return mapper.readValue(response.getContentAsString(), AlertsRequest.class);
}
private AlertsResource.Plugin findPlugin(List<AlertsResource.Plugin> plugins, String name) {
return plugins.stream()
.filter(p -> name.equals(p.getName()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("plugin " + name + " not found in request"));
}
}

View File

@@ -39,12 +39,8 @@ import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.AnonymousMode;
import java.net.URI;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -106,6 +102,7 @@ class ScmConfigurationToConfigDtoMapperTest {
assertThat(dto.isEnabledXsrfProtection()).isTrue();
assertThat(dto.getNamespaceStrategy()).isEqualTo("username");
assertThat(dto.getLoginInfoUrl()).isEqualTo("https://scm-manager.org/login-info");
assertThat(dto.getAlertsUrl()).isEqualTo("https://alerts.scm-manager.org/api/v1/alerts");
assertThat(dto.getReleaseFeedUrl()).isEqualTo("https://www.scm-manager.org/download/rss.xml");
assertThat(dto.getMailDomainName()).isEqualTo("scm-manager.local");
assertThat(dto.getEmergencyContacts()).contains(expectedUsers);
@@ -169,6 +166,7 @@ class ScmConfigurationToConfigDtoMapperTest {
config.setEnabledXsrfProtection(true);
config.setNamespaceStrategy("username");
config.setLoginInfoUrl("https://scm-manager.org/login-info");
config.setAlertsUrl("https://alerts.scm-manager.org/api/v1/alerts");
config.setReleaseFeedUrl("https://www.scm-manager.org/download/rss.xml");
config.setEmergencyContacts(Sets.newSet(expectedUsers));
return config;

View File

@@ -21,166 +21,143 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.plugin;
//~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.Function;
import com.google.common.collect.Lists;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import static com.google.common.collect.ImmutableSet.of;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
//~--- JDK imports ------------------------------------------------------------
import sonia.scm.Stage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static com.google.common.collect.ImmutableSet.of;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
/**
*
* @author Sebastian Sdorra
*/
public class PluginTreeTest
{
public class PluginTreeTest {
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();
/**
* Method description
*
*
* @throws IOException
*/
@Test(expected = PluginConditionFailedException.class)
public void testPluginConditionFailed() throws IOException
{
PluginCondition condition = new PluginCondition("999",
new ArrayList<String>(), "hit");
InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, createInfo("a", "1"), null, condition,
false, null, null);
public void testPluginConditionFailed() throws IOException {
PluginCondition condition = new PluginCondition("999", new ArrayList<>(), "hit");
InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, createInfo("a", "1"), null, condition,
false, null, null);
ExplodedSmp smp = createSmp(plugin);
new PluginTree(smp).getLeafLastNodes();
new PluginTree(Stage.PRODUCTION, smp).getLeafLastNodes();
}
/**
* Method description
*
*
* @throws IOException
*/
@Test(expected = PluginNotInstalledException.class)
public void testPluginNotInstalled() throws IOException
{
new PluginTree(createSmpWithDependency("b", "a")).getLeafLastNodes();
@Test(expected = PluginConditionFailedException.class)
public void testPluginConditionFailedInDevelopmentStage() throws IOException {
PluginCondition condition = new PluginCondition("999", new ArrayList<>(), "hit");
InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, createInfo("a", "1"), null, condition,
false, null, null);
ExplodedSmp smp = createSmp(plugin);
new PluginTree(Stage.DEVELOPMENT, smp).getLeafLastNodes();
}
/**
* Method description
*
*
* @throws IOException
*/
@Test
public void testNodes() throws IOException
{
public void testSkipCorePluginValidationOnDevelopment() throws IOException {
PluginCondition condition = new PluginCondition("999", new ArrayList<>(), "hit");
InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(2, createInfo("a", "1"), null, condition,
false, null, null);
// make it core
ExplodedSmp smp = createSmp(plugin);
Path path = smp.getPath();
Files.createFile(path.resolve(PluginConstants.FILE_CORE));
List<PluginNode> nodes = new PluginTree(Stage.DEVELOPMENT, smp).getLeafLastNodes();
assertFalse(nodes.isEmpty());
}
@Test(expected = PluginNotInstalledException.class)
public void testPluginNotInstalled() throws IOException {
new PluginTree(Stage.PRODUCTION, createSmpWithDependency("b", "a")).getLeafLastNodes();
}
@Test
public void testNodes() throws IOException {
List<ExplodedSmp> smps = createSmps("a", "b", "c");
List<String> nodes = unwrapIds(new PluginTree(smps).getLeafLastNodes());
List<String> nodes = unwrapIds(new PluginTree(Stage.PRODUCTION, smps).getLeafLastNodes());
assertThat(nodes, containsInAnyOrder("a", "b", "c"));
}
/**
* Method description
*
*
* @throws IOException
*/
@Test(expected = PluginException.class)
public void testScmVersion() throws IOException
{
public void testScmVersion() throws IOException {
InstalledPluginDescriptor plugin = new InstalledPluginDescriptor(1, createInfo("a", "1"), null, null, false,
null, null);
null, null);
ExplodedSmp smp = createSmp(plugin);
new PluginTree(smp).getLeafLastNodes();
new PluginTree(Stage.PRODUCTION, smp).getLeafLastNodes();
}
/**
* Method description
*
*
* @throws IOException
*/
@Test
public void testSimpleDependencies() throws IOException
{
//J-
ExplodedSmp[] smps = new ExplodedSmp[] {
public void testSimpleDependencies() throws IOException {
ExplodedSmp[] smps = new ExplodedSmp[]{
createSmpWithDependency("a"),
createSmpWithDependency("b", "a"),
createSmpWithDependency("c", "a", "b")
};
//J+
PluginTree tree = new PluginTree(smps);
PluginTree tree = new PluginTree(Stage.PRODUCTION, smps);
List<PluginNode> nodes = tree.getLeafLastNodes();
System.out.println(tree);
assertThat(unwrapIds(nodes), contains("a", "b", "c"));
}
@Test
public void testComplexDependencies() throws IOException
{
//J-
public void testComplexDependencies() throws IOException {
ExplodedSmp[] smps = new ExplodedSmp[]{
createSmpWithDependency("a", "b", "c", "d"),
createSmpWithDependency("b", "c"),
createSmpWithDependency("c"),
createSmpWithDependency("d")
};
//J+
PluginTree tree = new PluginTree(smps);
PluginTree tree = new PluginTree(Stage.PRODUCTION, smps);
List<PluginNode> nodes = tree.getLeafLastNodes();
System.out.println(tree);
assertThat(unwrapIds(nodes), contains("d", "c", "b", "a"));
}
@Test
public void testWithOptionalDependency() throws IOException {
ExplodedSmp[] smps = new ExplodedSmp[] {
ExplodedSmp[] smps = new ExplodedSmp[]{
createSmpWithDependency("a"),
createSmpWithDependency("b", null, of("a")),
createSmpWithDependency("c", null, of("a", "b"))
};
PluginTree tree = new PluginTree(smps);
PluginTree tree = new PluginTree(Stage.PRODUCTION, smps);
List<PluginNode> nodes = tree.getLeafLastNodes();
System.out.println(tree);
assertThat(unwrapIds(nodes), contains("a", "b", "c"));
}
@Test
public void testRealWorldDependencies() throws IOException {
//J-
ExplodedSmp[] smps = new ExplodedSmp[]{
createSmpWithDependency("scm-editor-plugin"),
createSmpWithDependency("scm-ci-plugin"),
@@ -206,7 +183,7 @@ public class PluginTreeTest
createSmpWithDependency("scm-script-plugin"),
createSmpWithDependency("scm-activity-plugin"),
createSmpWithDependency("scm-mail-plugin"),
createSmpWithDependency("scm-branchwp-plugin", of(), of("scm-editor-plugin", "scm-review-plugin", "scm-mail-plugin" )),
createSmpWithDependency("scm-branchwp-plugin", of(), of("scm-editor-plugin", "scm-review-plugin", "scm-mail-plugin")),
createSmpWithDependency("scm-notify-plugin", "scm-mail-plugin"),
createSmpWithDependency("scm-redmine-plugin", "scm-issuetracker-plugin"),
createSmpWithDependency("scm-jira-plugin", "scm-mail-plugin", "scm-issuetracker-plugin"),
@@ -214,17 +191,10 @@ public class PluginTreeTest
createSmpWithDependency("scm-pathwp-plugin", of(), of("scm-editor-plugin")),
createSmpWithDependency("scm-cockpit-legacy-plugin", "scm-statistic-plugin", "scm-rest-legacy-plugin", "scm-activity-plugin")
};
//J+
Arrays.stream(smps)
.forEach(smp -> System.out.println(smp.getPlugin()));
PluginTree tree = new PluginTree(smps);
PluginTree tree = new PluginTree(Stage.PRODUCTION, smps);
List<PluginNode> nodes = tree.getLeafLastNodes();
System.out.println(tree);
assertEachParentHasChild(nodes, "scm-review-plugin", "scm-branchwp-plugin");
}
@@ -239,18 +209,15 @@ public class PluginTreeTest
assertEachParentHasChild(pluginNode.getChildren(), parentName, childName);
}
@Test
public void testWithDeepOptionalDependency() throws IOException {
ExplodedSmp[] smps = new ExplodedSmp[] {
ExplodedSmp[] smps = new ExplodedSmp[]{
createSmpWithDependency("a"),
createSmpWithDependency("b", "a"),
createSmpWithDependency("c", null, of("b"))
};
PluginTree tree = new PluginTree(smps);
System.out.println(tree);
PluginTree tree = new PluginTree(Stage.PRODUCTION, smps);
List<PluginNode> nodes = tree.getLeafLastNodes();
@@ -259,28 +226,18 @@ public class PluginTreeTest
@Test
public void testWithNonExistentOptionalDependency() throws IOException {
ExplodedSmp[] smps = new ExplodedSmp[] {
ExplodedSmp[] smps = new ExplodedSmp[]{
createSmpWithDependency("a", null, of("b"))
};
PluginTree tree = new PluginTree(smps);
PluginTree tree = new PluginTree(Stage.PRODUCTION, smps);
List<PluginNode> nodes = tree.getLeafLastNodes();
assertThat(unwrapIds(nodes), containsInAnyOrder("a"));
}
/**
* Method description
*
*
* @param name
* @param version
*
* @return
*/
private PluginInformation createInfo(String name,
String version)
{
String version) {
PluginInformation info = new PluginInformation();
info.setName(name);
@@ -289,58 +246,21 @@ public class PluginTreeTest
return info;
}
/**
* Method description
*
*
* @param plugin
*
* @return
*
* @throws IOException
*/
private ExplodedSmp createSmp(InstalledPluginDescriptor plugin) throws IOException
{
return new ExplodedSmp(tempFolder.newFile().toPath(), plugin);
private ExplodedSmp createSmp(InstalledPluginDescriptor plugin) throws IOException {
return new ExplodedSmp(tempFolder.newFolder().toPath(), plugin);
}
/**
* Method description
*
*
* @param name
*
* @return
*
* @throws IOException
*/
private ExplodedSmp createSmp(String name) throws IOException
{
private ExplodedSmp createSmp(String name) throws IOException {
return createSmp(new InstalledPluginDescriptor(2, createInfo(name, "1.0.0"), null, null,
false, null, null));
}
/**
* Method description
*
*
* @param name
* @param dependencies
*
* @return
*
* @throws IOException
*/
private ExplodedSmp createSmpWithDependency(String name,
String... dependencies)
throws IOException
{
String... dependencies)
throws IOException {
Set<String> dependencySet = new HashSet<>();
for (String d : dependencies)
{
dependencySet.add(d);
}
Collections.addAll(dependencySet, dependencies);
return createSmpWithDependency(name, dependencySet, null);
}
@@ -357,52 +277,18 @@ public class PluginTreeTest
return createSmp(plugin);
}
/**
* Method description
*
*
* @param names
*
* @return
*
* @throws IOException
*/
private List<ExplodedSmp> createSmps(String... names) throws IOException
{
private List<ExplodedSmp> createSmps(String... names) throws IOException {
List<ExplodedSmp> smps = Lists.newArrayList();
for (String name : names)
{
for (String name : names) {
smps.add(createSmp(name));
}
return smps;
}
/**
* Method description
*
*
* @param nodes
*
* @return
*/
private List<String> unwrapIds(List<PluginNode> nodes)
{
return Lists.transform(nodes, new Function<PluginNode, String>()
{
@Override
public String apply(PluginNode input)
{
return input.getId();
}
});
private List<String> unwrapIds(List<PluginNode> nodes) {
return nodes.stream().map(PluginNode::getId).collect(Collectors.toList());
}
//~--- fields ---------------------------------------------------------------
/** Field description */
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();
}