diff --git a/api/src/main/openapi/components/schemas/extensions/extension-test-check-status.yaml b/api/src/main/openapi/components/schemas/extensions/extension-test-check-status.yaml new file mode 100644 index 0000000000..d562b3b8f3 --- /dev/null +++ b/api/src/main/openapi/components/schemas/extensions/extension-test-check-status.yaml @@ -0,0 +1,21 @@ +# 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. +type: string +enum: +- PASSED +- FAILED +- SKIPPED \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/extensions/extension-test-check.yaml b/api/src/main/openapi/components/schemas/extensions/extension-test-check.yaml new file mode 100644 index 0000000000..fce9109059 --- /dev/null +++ b/api/src/main/openapi/components/schemas/extensions/extension-test-check.yaml @@ -0,0 +1,29 @@ +# 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. +type: object +properties: + name: + type: string + example: "connection" + status: + $ref: "./extension-test-check-status.yaml" + message: + type: string + example: "Connection failed" +required: +- name +- status \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/extensions/test-extension-request.yaml b/api/src/main/openapi/components/schemas/extensions/test-extension-request.yaml new file mode 100644 index 0000000000..5b45d04914 --- /dev/null +++ b/api/src/main/openapi/components/schemas/extensions/test-extension-request.yaml @@ -0,0 +1,21 @@ +# 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. +type: object +properties: + config: + type: object + additionalProperties: true \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/extensions/test-extension-response.yaml b/api/src/main/openapi/components/schemas/extensions/test-extension-response.yaml new file mode 100644 index 0000000000..a122ac1357 --- /dev/null +++ b/api/src/main/openapi/components/schemas/extensions/test-extension-response.yaml @@ -0,0 +1,24 @@ +# 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. +type: object +properties: + checks: + type: array + items: + $ref: "./extension-test-check.yaml" +required: +- checks \ No newline at end of file diff --git a/api/src/main/openapi/openapi.yaml b/api/src/main/openapi/openapi.yaml index dc44ffc671..1299937782 100644 --- a/api/src/main/openapi/openapi.yaml +++ b/api/src/main/openapi/openapi.yaml @@ -130,6 +130,8 @@ paths: $ref: "./paths/extension-points__name__extensions__name__config.yaml" /extension-points/{extensionPointName}/extensions/{extensionName}/config-schema: $ref: "./paths/extension-points__name__extensions__name__config-schema.yaml" + /extension-points/{extensionPointName}/extensions/{extensionName}/test: + $ref: "./paths/extension-points__name__extensions__name__test.yaml" /metrics/portfolio/current: $ref: "./paths/metrics_portfolio_current.yaml" /metrics/vulnerabilities: diff --git a/api/src/main/openapi/paths/components.yaml b/api/src/main/openapi/paths/components.yaml index f81ecae293..0fdb354523 100644 --- a/api/src/main/openapi/paths/components.yaml +++ b/api/src/main/openapi/paths/components.yaml @@ -34,7 +34,7 @@ post: content: application/problem+json: schema: - oneOf: + anyOf: - $ref: "../components/schemas/invalid-request-problem-details.yaml" - $ref: "../components/schemas/problem-details.yaml" "401": diff --git a/api/src/main/openapi/paths/extension-points__name__extensions__name__config.yaml b/api/src/main/openapi/paths/extension-points__name__extensions__name__config.yaml index d23c6c68fa..4a0d55b3b5 100644 --- a/api/src/main/openapi/paths/extension-points__name__extensions__name__config.yaml +++ b/api/src/main/openapi/paths/extension-points__name__extensions__name__config.yaml @@ -58,7 +58,11 @@ put: description: |- Updates the configuration of an extension. - Requires the `SYSTEM_CONFIGURATION` or `SYSTEM_CONFIGURATION_READ` permission. + **Do not use clear text credentials in the supplied config**. + Fields annotated with `x-secret-ref` in the config schema expect + a name of a managed secret, which is resolved internally by the API. + + Requires the `SYSTEM_CONFIGURATION` or `SYSTEM_CONFIGURATION_UPDATE` permission. tags: - Extensions parameters: diff --git a/api/src/main/openapi/paths/extension-points__name__extensions__name__test.yaml b/api/src/main/openapi/paths/extension-points__name__extensions__name__test.yaml new file mode 100644 index 0000000000..466d640d1a --- /dev/null +++ b/api/src/main/openapi/paths/extension-points__name__extensions__name__test.yaml @@ -0,0 +1,79 @@ +# 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. +post: + operationId: testExtension + summary: Test extension + description: |- + Tests an extension. + + If the extension is configurable (i.e. `/config-schema` returns status `200`), + a valid configuration **must** be provided in the test request. + The configuration is validated against the applicable JSON schema. + + **Do not use clear text credentials in the supplied config**. + Fields annotated with `x-secret-ref` in the config schema expect + a name of a managed secret, which is resolved internally by the API. + + Test results contain one or more checks, each of which can have a status of + `PASSED`, `FAILED`, or `SKIPPED`. If *at least one* check is `FAILED`, + the entire test should be considered `FAILED`. + + Requires the `SYSTEM_CONFIGURATION` or `SYSTEM_CONFIGURATION_UPDATE` permission. + tags: + - Extensions + parameters: + - name: extensionPointName + description: Name of the extension point + in: path + required: true + schema: + type: string + - name: extensionName + description: Name of the extension + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "../components/schemas/extensions/test-extension-request.yaml" + responses: + "200": + description: Test result + content: + application/json: + schema: + $ref: "../components/schemas/extensions/test-extension-response.yaml" + "400": + description: Bad Request + content: + application/problem+json: + schema: + anyOf: + - $ref: "../components/schemas/json-schema-validation-problem-details.yaml" + - $ref: "../components/schemas/problem-details.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + "404": + $ref: "../components/responses/generic-not-found-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/paths/secrets.yaml b/api/src/main/openapi/paths/secrets.yaml index 9f81a902b6..0e14720dce 100644 --- a/api/src/main/openapi/paths/secrets.yaml +++ b/api/src/main/openapi/paths/secrets.yaml @@ -73,7 +73,7 @@ post: content: application/problem+json: schema: - oneOf: + anyOf: - $ref: "../components/schemas/invalid-request-problem-details.yaml" - $ref: "../components/schemas/problem-details.yaml" "401": diff --git a/api/src/main/openapi/paths/secrets__name_.yaml b/api/src/main/openapi/paths/secrets__name_.yaml index cea5d4f911..2633704890 100644 --- a/api/src/main/openapi/paths/secrets__name_.yaml +++ b/api/src/main/openapi/paths/secrets__name_.yaml @@ -81,7 +81,7 @@ patch: content: application/problem+json: schema: - oneOf: + anyOf: - $ref: "../components/schemas/invalid-request-problem-details.yaml" - $ref: "../components/schemas/problem-details.yaml" "401": @@ -120,7 +120,7 @@ delete: content: application/problem+json: schema: - oneOf: + anyOf: - $ref: "../components/schemas/invalid-request-problem-details.yaml" - $ref: "../components/schemas/problem-details.yaml" "401": diff --git a/api/src/main/openapi/paths/team-memberships.yaml b/api/src/main/openapi/paths/team-memberships.yaml index 6216617b18..fbd517ef68 100644 --- a/api/src/main/openapi/paths/team-memberships.yaml +++ b/api/src/main/openapi/paths/team-memberships.yaml @@ -80,7 +80,7 @@ post: content: application/problem+json: schema: - oneOf: + anyOf: - $ref: "../components/schemas/invalid-request-problem-details.yaml" - $ref: "../components/schemas/problem-details.yaml" "401": diff --git a/api/src/main/openapi/paths/teams.yaml b/api/src/main/openapi/paths/teams.yaml index fbe916d2fe..387574e5d5 100644 --- a/api/src/main/openapi/paths/teams.yaml +++ b/api/src/main/openapi/paths/teams.yaml @@ -67,7 +67,7 @@ post: content: application/problem+json: schema: - oneOf: + anyOf: - $ref: "../components/schemas/invalid-request-problem-details.yaml" - $ref: "../components/schemas/problem-details.yaml" "401": diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/ExtensionsResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/ExtensionsResource.java index 28bc77f0f3..5ff2a8a6d4 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v2/ExtensionsResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/ExtensionsResource.java @@ -20,6 +20,7 @@ import alpine.server.auth.PermissionRequired; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.inject.Inject; import jakarta.json.Json; @@ -33,13 +34,19 @@ import org.dependencytrack.api.v2.model.ListExtensionPointsResponseItem; import org.dependencytrack.api.v2.model.ListExtensionsResponse; import org.dependencytrack.api.v2.model.ListExtensionsResponseItem; +import org.dependencytrack.api.v2.model.TestExtensionRequest; +import org.dependencytrack.api.v2.model.TestExtensionResponse; import org.dependencytrack.api.v2.model.UpdateExtensionConfigRequest; import org.dependencytrack.auth.Permissions; +import org.dependencytrack.common.MdcScope; import org.dependencytrack.persistence.jdbi.ExtensionConfigDao; import org.dependencytrack.plugin.ExtensionPointMetadata; import org.dependencytrack.plugin.PluginManager; import org.dependencytrack.plugin.api.ExtensionFactory; import org.dependencytrack.plugin.api.ExtensionPoint; +import org.dependencytrack.plugin.api.ExtensionTestCheck; +import org.dependencytrack.plugin.api.ExtensionTestResult; +import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; import org.dependencytrack.plugin.runtime.config.RuntimeConfigMapper; import org.dependencytrack.plugin.runtime.config.UnresolvableSecretException; @@ -54,6 +61,8 @@ import java.util.Map; import java.util.SequencedCollection; +import static org.dependencytrack.common.MdcKeys.MDC_EXTENSION_NAME; +import static org.dependencytrack.common.MdcKeys.MDC_EXTENSION_POINT_NAME; import static org.dependencytrack.persistence.jdbi.JdbiFactory.inJdbiTransaction; import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; @@ -129,7 +138,6 @@ public Response listExtensions(String extensionPointName) { } @Override - @SuppressWarnings("rawtypes") @PermissionRequired({ Permissions.Constants.SYSTEM_CONFIGURATION, Permissions.Constants.SYSTEM_CONFIGURATION_READ @@ -139,7 +147,7 @@ public Response getExtensionConfig( String extensionName) { final Class extensionPointClass = getExtensionPointClass(extensionPointName); - final ExtensionFactory extensionFactory = + final ExtensionFactory extensionFactory = getExtensionFactory(extensionPointClass, extensionName); if (extensionFactory.runtimeConfigSpec() == null) { @@ -171,7 +179,6 @@ public Response getExtensionConfig( } @Override - @SuppressWarnings("rawtypes") @PermissionRequired({ Permissions.Constants.SYSTEM_CONFIGURATION, Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE @@ -182,7 +189,7 @@ public Response updateExtensionConfig( UpdateExtensionConfigRequest request) { final Class extensionPointClass = getExtensionPointClass(extensionPointName); - final ExtensionFactory extensionFactory = + final ExtensionFactory extensionFactory = getExtensionFactory(extensionPointClass, extensionName); final RuntimeConfigSpec runtimeConfigSpec = extensionFactory.runtimeConfigSpec(); @@ -194,7 +201,8 @@ public Response updateExtensionConfig( // so we have to serialize it first. final String configJson = Json.createObjectBuilder(request.getConfig()).build().toString(); - validateConfig(configJson, runtimeConfigSpec); + // Throws when config is invalid or secrets cannot be resolved. + validateConfigAndResolveSecrets(configJson, runtimeConfigSpec); final boolean updated = inJdbiTransaction( getAlpineRequest(), @@ -234,6 +242,57 @@ public Response getExtensionConfigSchema( return Response.ok(runtimeConfigSpec.schema()).build(); } + @Override + @PermissionRequired({ + Permissions.Constants.SYSTEM_CONFIGURATION, + Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE + }) + public Response testExtension( + String extensionPointName, + String extensionName, + TestExtensionRequest request) { + final Class extensionPointClass = + getExtensionPointClass(extensionPointName); + final ExtensionFactory extensionFactory = + getExtensionFactory(extensionPointClass, extensionName); + + try (var ignoredMdcScope = new MdcScope(Map.ofEntries( + Map.entry(MDC_EXTENSION_POINT_NAME, extensionPointName), + Map.entry(MDC_EXTENSION_NAME, extensionName)))) { + LOGGER.info( + SecurityMarkers.SECURITY_AUDIT, + "Extension test requested with configuration: {}", + request.getConfig()); + } + + RuntimeConfig runtimeConfig = null; + final RuntimeConfigSpec runtimeConfigSpec = extensionFactory.runtimeConfigSpec(); + if (runtimeConfigSpec == null) { + if (request.getConfig() != null) { + throw new BadRequestException("The extension does not support configuration"); + } + } else { + final String configJson = Json.createObjectBuilder(request.getConfig()).build().toString(); + final JsonNode configNode = validateConfigAndResolveSecrets(configJson, runtimeConfigSpec); + runtimeConfig = configMapper.convert(configNode, runtimeConfigSpec.configClass()); + } + + final ExtensionTestResult testResult; + try { + testResult = extensionFactory.test(runtimeConfig); + } catch (UnsupportedOperationException e) { + throw new BadRequestException("The extension does not support testing"); + } + + final var response = TestExtensionResponse.builder() + .checks(testResult.checks().stream() + .map(ExtensionsResource::convert) + .toList()) + .build(); + + return Response.ok(response).build(); + } + private Class getExtensionPointClass(String extensionPointName) { return pluginManager.getExtensionPoints().stream() .filter(spec -> spec.name().equals(extensionPointName)) @@ -251,7 +310,7 @@ private ExtensionFactory getExtensionFactory( .orElseThrow(NotFoundException::new); } - private void validateConfig(String configJson, RuntimeConfigSpec configSpec) { + private JsonNode validateConfigAndResolveSecrets(String configJson, RuntimeConfigSpec configSpec) { final var configNode = configMapper.validateJson(configJson, configSpec); try { @@ -259,6 +318,20 @@ private void validateConfig(String configJson, RuntimeConfigSpec configSpec) { } catch (UnresolvableSecretException e) { throw new BadRequestException(e.getMessage()); } + + return configNode; + } + + private static org.dependencytrack.api.v2.model.ExtensionTestCheck convert(ExtensionTestCheck check) { + return org.dependencytrack.api.v2.model.ExtensionTestCheck.builder() + .name(check.name()) + .status(switch (check.status()) { + case FAILED -> org.dependencytrack.api.v2.model.ExtensionTestCheckStatus.FAILED; + case PASSED -> org.dependencytrack.api.v2.model.ExtensionTestCheckStatus.PASSED; + case SKIPPED -> org.dependencytrack.api.v2.model.ExtensionTestCheckStatus.SKIPPED; + }) + .message(check.message()) + .build(); } } diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/ExtensionsResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/ExtensionsResourceTest.java index 8fba0cf5fb..ebe4b2c749 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v2/ExtensionsResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/ExtensionsResourceTest.java @@ -30,6 +30,7 @@ import org.dependencytrack.plugin.api.ExtensionFactory; import org.dependencytrack.plugin.api.ExtensionPoint; import org.dependencytrack.plugin.api.ExtensionPointSpec; +import org.dependencytrack.plugin.api.ExtensionTestResult; import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.dependencytrack.plugin.api.config.RuntimeConfigSchemaSource; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; @@ -37,8 +38,10 @@ import org.dependencytrack.secret.management.SecretManager; import org.glassfish.jersey.inject.hk2.AbstractBinder; import org.jspecify.annotations.NonNull; -import org.junit.jupiter.api.AfterAll; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -49,7 +52,7 @@ import static org.dependencytrack.persistence.jdbi.JdbiFactory.useJdbiTransaction; import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; -public class ExtensionsResourceTest extends ResourceTest { +class ExtensionsResourceTest extends ResourceTest { private static PluginManager pluginManager; private static SecretManager secretManager; @@ -68,27 +71,32 @@ protected void configure() { @BeforeAll static void beforeAll() { secretManager = new TestSecretManager(); + } + @BeforeEach + void beforeEach() { pluginManager = new PluginManager( new SmallRyeConfigBuilder().build(), secretManager::getSecretValue, List.of(DummyExtensionPoint.class)); - pluginManager.loadPlugins(List.of( - () -> List.of(new DummyExtensionFactory()))); } - @AfterAll - static void afterAll() { + @AfterEach + void afterEach() { if (pluginManager != null) { pluginManager.close(); } } @Test - public void listExtensionPointsShouldListAllExtensionPoints() { + void listExtensionPointsShouldListAllExtensionPoints() { + pluginManager.loadPlugins(List.of( + () -> List.of(new DummyExtensionFactory()))); + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_READ); - final Response response = jersey.target("/extension-points") + final Response response = jersey + .target("/extension-points") .request() .header(X_API_KEY, apiKey) .get(); @@ -105,10 +113,14 @@ public void listExtensionPointsShouldListAllExtensionPoints() { } @Test - public void listExtensionsShouldListAllExtensions() { + void listExtensionsShouldListAllExtensions() { + pluginManager.loadPlugins(List.of( + () -> List.of(new DummyExtensionFactory()))); + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_READ); - final Response response = jersey.target("/extension-points/dummy/extensions") + final Response response = jersey + .target("/extension-points/dummy/extensions") .request() .header(X_API_KEY, apiKey) .get(); @@ -125,10 +137,11 @@ public void listExtensionsShouldListAllExtensions() { } @Test - public void listExtensionsShouldReturnNotFoundWhenExtensionPointDoesNotExist() { + void listExtensionsShouldReturnNotFoundWhenExtensionPointDoesNotExist() { initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_READ); - final Response response = jersey.target("/extension-points/doesNotExist/extensions") + final Response response = jersey + .target("/extension-points/doesNotExist/extensions") .request() .header(X_API_KEY, apiKey) .get(); @@ -144,7 +157,10 @@ public void listExtensionsShouldReturnNotFoundWhenExtensionPointDoesNotExist() { } @Test - public void getExtensionConfigShouldReturnConfig() { + void getExtensionConfigShouldReturnConfig() { + pluginManager.loadPlugins(List.of( + () -> List.of(new DummyExtensionFactory()))); + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_READ); useJdbiTransaction( @@ -155,7 +171,8 @@ public void getExtensionConfigShouldReturnConfig() { } """)); - final Response response = jersey.target("/extension-points/dummy/extensions/dummy.extension/config") + final Response response = jersey + .target("/extension-points/dummy/extensions/dummy.extension/config") .request() .header(X_API_KEY, apiKey) .get(); @@ -170,10 +187,11 @@ public void getExtensionConfigShouldReturnConfig() { } @Test - public void getExtensionConfigShouldReturnNotFoundWhenNotExists() { + void getExtensionConfigShouldReturnNotFoundWhenNotExists() { initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_READ); - final Response response = jersey.target("/extension-points/dummy/extensions/doesNotExist/config") + final Response response = jersey + .target("/extension-points/dummy/extensions/doesNotExist/config") .request() .header(X_API_KEY, apiKey) .get(); @@ -189,10 +207,14 @@ public void getExtensionConfigShouldReturnNotFoundWhenNotExists() { } @Test - public void updateExtensionConfigShouldReturnNoContent() { + void updateExtensionConfigShouldReturnNoContent() { + pluginManager.loadPlugins(List.of( + () -> List.of(new DummyExtensionFactory()))); + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_UPDATE); - final Response response = jersey.target("/extension-points/dummy/extensions/dummy.extension/config") + final Response response = jersey + .target("/extension-points/dummy/extensions/dummy.extension/config") .request() .header(X_API_KEY, apiKey) .put(Entity.json(/* language=JSON */ """ @@ -217,7 +239,10 @@ public void updateExtensionConfigShouldReturnNoContent() { } @Test - public void updateExtensionConfigShouldReturnNotModifiedWhenUnchanged() { + void updateExtensionConfigShouldReturnNotModifiedWhenUnchanged() { + pluginManager.loadPlugins(List.of( + () -> List.of(new DummyExtensionFactory()))); + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_UPDATE); useJdbiTransaction( @@ -230,7 +255,8 @@ public void updateExtensionConfigShouldReturnNotModifiedWhenUnchanged() { } """)); - final Response response = jersey.target("/extension-points/dummy/extensions/dummy.extension/config") + final Response response = jersey + .target("/extension-points/dummy/extensions/dummy.extension/config") .request() .header(X_API_KEY, apiKey) .put(Entity.json(/* language=JSON */ """ @@ -246,10 +272,14 @@ public void updateExtensionConfigShouldReturnNotModifiedWhenUnchanged() { } @Test - public void updateExtensionConfigShouldReturnBadRequestWhenInvalid() { + void updateExtensionConfigShouldReturnBadRequestWhenInvalid() { + pluginManager.loadPlugins(List.of( + () -> List.of(new DummyExtensionFactory()))); + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_UPDATE); - final Response response = jersey.target("/extension-points/dummy/extensions/dummy.extension/config") + final Response response = jersey + .target("/extension-points/dummy/extensions/dummy.extension/config") .request() .header(X_API_KEY, apiKey) .put(Entity.json(/* language=JSON */ """ @@ -280,10 +310,14 @@ public void updateExtensionConfigShouldReturnBadRequestWhenInvalid() { } @Test - public void getExtensionConfigSchemaShouldReturnConfigSchema() { + void getExtensionConfigSchemaShouldReturnConfigSchema() { + pluginManager.loadPlugins(List.of( + () -> List.of(new DummyExtensionFactory()))); + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_READ); - final Response response = jersey.target("/extension-points/dummy/extensions/dummy.extension/config-schema") + final Response response = jersey + .target("/extension-points/dummy/extensions/dummy.extension/config-schema") .request() .header(X_API_KEY, apiKey) .get(); @@ -302,7 +336,6 @@ public void getExtensionConfigSchemaShouldReturnConfigSchema() { "description": "An optional string" } }, - "additionalProperties": false, "required": [ "requiredString" ] @@ -310,6 +343,136 @@ public void getExtensionConfigSchemaShouldReturnConfigSchema() { """); } + @Test + void testExtensionShouldReturnTestResultWhenTestPassed() { + pluginManager.loadPlugins(List.of( + () -> List.of(new TestableExtensionFactory()))); + + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_UPDATE); + + final Response response = jersey + .target("/extension-points/dummy/extensions/testable.extension/test") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(/* language=JSON */ """ + { + "config": { + "outcome": "PASSED" + } + } + """)); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "checks": [ + { + "name": "name", + "status": "PASSED" + } + ] + } + """); + } + + @Test + void testExtensionShouldReturnTestResultWhenTestFailed() { + pluginManager.loadPlugins(List.of( + () -> List.of(new TestableExtensionFactory()))); + + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_UPDATE); + + final Response response = jersey + .target("/extension-points/dummy/extensions/testable.extension/test") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(/* language=JSON */ """ + { + "config": { + "outcome": "FAILED" + } + } + """)); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "checks": [ + { + "name": "name", + "status": "FAILED", + "message": "message" + } + ] + } + """); + } + + @Test + void testExtensionShouldReturnBadRequestWhenExtensionDoesNotSupportTesting() { + pluginManager.loadPlugins(List.of( + () -> List.of(new DummyExtensionFactory()))); + + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_UPDATE); + + final Response response = jersey + .target("/extension-points/dummy/extensions/dummy.extension/test") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(/* language=JSON */ """ + { + "config": { + "requiredString": "foo" + } + } + """)); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "status": 400, + "type": "about:blank", + "title": "Bad Request", + "detail": "The extension does not support testing" + } + """); + } + + @Test + void testExtensionShouldReturnBadRequestWhenConfigIsInvalid() { + pluginManager.loadPlugins(List.of( + () -> List.of(new TestableExtensionFactory()))); + + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION_UPDATE); + + final Response response = jersey + .target("/extension-points/dummy/extensions/testable.extension/test") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(/* language=JSON */ """ + { + "config": { + "outcome": "invalid" + } + } + """)); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "status": 400, + "type": "about:blank", + "title":"JSON Schema Validation Failed", + "detail": "The provided configuration failed JSON schema validation.", + "errors": [ + { + "evaluation_path": "$.properties.outcome.enum", + "instance_location": "$.outcome", + "keyword": "enum", + "message": "$.outcome: does not have a value in the enumeration [\\"PASSED\\", \\"FAILED\\"]", + "schema_location": "#/properties/outcome/enum" + } + ] + } + """); + } + @ExtensionPointSpec(name = "dummy", required = false) private interface DummyExtensionPoint extends ExtensionPoint { } @@ -358,7 +521,6 @@ public RuntimeConfigSpec runtimeConfigSpec() { "description": "An optional string" } }, - "additionalProperties": false, "required": [ "requiredString" ] @@ -377,4 +539,67 @@ public DummyExtensionPoint create() { } + private record TestableRuntimeConfig(String outcome) implements RuntimeConfig { + } + + private static class TestableExtensionFactory implements ExtensionFactory { + + @Override + public @NonNull String extensionName() { + return "testable.extension"; + } + + @Override + public Class extensionClass() { + return DummyExtension.class; + } + + @Override + public int priority() { + return 0; + } + + @Override + public void init(ExtensionContext ctx) { + } + + @Override + public DummyExtensionPoint create() { + return new DummyExtension(); + } + + @Override + public ExtensionTestResult test(@Nullable RuntimeConfig runtimeConfig) { + final var testConfig = (TestableRuntimeConfig) runtimeConfig; + if ("PASSED".equals(testConfig.outcome())) { + return ExtensionTestResult.ofChecks("name").pass("name"); + } else { + return ExtensionTestResult.ofChecks("name").fail("name", "message"); + } + } + + @Override + public @Nullable RuntimeConfigSpec runtimeConfigSpec() { + final var defaultConfig = new TestableRuntimeConfig(null); + return new RuntimeConfigSpec( + defaultConfig, + new RuntimeConfigSchemaSource.Literal(/* language=JSON */ """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "PASSED", + "FAILED" + ] + } + } + } + """)); + } + + } + } \ No newline at end of file diff --git a/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionContext.java b/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionContext.java index 9f7b72ac4b..3a2dc3947a 100644 --- a/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionContext.java +++ b/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionContext.java @@ -19,11 +19,13 @@ package org.dependencytrack.plugin.api; import org.dependencytrack.plugin.api.config.ConfigRegistry; -import org.dependencytrack.plugin.api.storage.InMemoryExtensionKVStore; import org.dependencytrack.plugin.api.storage.ExtensionKVStore; +import org.dependencytrack.plugin.api.storage.InMemoryExtensionKVStore; +import org.jspecify.annotations.Nullable; import java.net.ProxySelector; -import java.util.Objects; + +import static java.util.Objects.requireNonNull; /** * @since 5.7.0 @@ -35,15 +37,15 @@ public final class ExtensionContext { private final ProxySelector proxySelector; public ExtensionContext( - final ConfigRegistry configRegistry, - final ExtensionKVStore kvStore, - final ProxySelector proxySelector) { - this.configRegistry = Objects.requireNonNull(configRegistry, "configRegistry must not be null"); - this.keyValueStore = Objects.requireNonNull(kvStore, "kvStore must not be null"); + ConfigRegistry configRegistry, + ExtensionKVStore kvStore, + @Nullable ProxySelector proxySelector) { + this.configRegistry = requireNonNull(configRegistry, "configRegistry must not be null"); + this.keyValueStore = requireNonNull(kvStore, "kvStore must not be null"); this.proxySelector = proxySelector != null ? proxySelector : ProxySelector.getDefault(); } - public ExtensionContext(final ConfigRegistry configRegistry) { + public ExtensionContext(ConfigRegistry configRegistry) { this(configRegistry, new InMemoryExtensionKVStore(), null); } diff --git a/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionFactory.java b/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionFactory.java index f4d1d84e3d..e997dacbcf 100644 --- a/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionFactory.java +++ b/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionFactory.java @@ -18,6 +18,7 @@ */ package org.dependencytrack.plugin.api; +import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; import org.jspecify.annotations.Nullable; @@ -47,6 +48,10 @@ public interface ExtensionFactory extends Closeable { */ int priority(); + /** + * @return A runtime config specification, or {@code null} when runtime configuration + * is not supported. + */ default @Nullable RuntimeConfigSpec runtimeConfigSpec() { return null; } @@ -70,6 +75,19 @@ public interface ExtensionFactory extends Closeable { */ T create(); + /** + * Performs a test whether the extension is operational with the provided runtime config. + * + * @param runtimeConfig The runtime config to test with. {@code null} when the extension + * does not support runtime configuration (i.e. {@link #runtimeConfigSpec()} + * also returns {@code null}). + * @return The test result. + * @throws UnsupportedOperationException When the extension does not support testing. + */ + default ExtensionTestResult test(@Nullable RuntimeConfig runtimeConfig) { + throw new UnsupportedOperationException(); + } + /** * {@inheritDoc} */ diff --git a/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionTestCheck.java b/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionTestCheck.java new file mode 100644 index 0000000000..592498f54a --- /dev/null +++ b/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionTestCheck.java @@ -0,0 +1,60 @@ +/* + * 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.api; + +import org.jspecify.annotations.Nullable; + +import static java.util.Objects.requireNonNull; + +/** + * @param name Name of the check. + * @param status Status of the check. + * @param message An optional, short, human-readable message. + * @since 5.7.0 + */ +public record ExtensionTestCheck( + String name, + Status status, + @Nullable String message) { + + public enum Status { + + /** + * The check was performed, and passed. + */ + PASSED, + + /** + * The check was performed, and failed. + */ + FAILED, + + /** + * The check was not performed. + */ + SKIPPED + + } + + public ExtensionTestCheck { + requireNonNull(name, "name must not be null"); + requireNonNull(status, "status must not be null"); + } + +} diff --git a/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionTestResult.java b/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionTestResult.java new file mode 100644 index 0000000000..63eb4a6f6c --- /dev/null +++ b/plugin/api/src/main/java/org/dependencytrack/plugin/api/ExtensionTestResult.java @@ -0,0 +1,89 @@ +/* + * 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.api; + +import org.dependencytrack.plugin.api.ExtensionTestCheck.Status; +import org.jspecify.annotations.Nullable; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static java.util.Objects.requireNonNull; + +/** + * Result of an extension test. + *

+ * A test result is composed of one or more {@link ExtensionTestCheck}s. + * Checks must be registered upfront, with their status defaulting to {@link Status#SKIPPED}. + * Use {@link #pass(String)} or {@link #fail(String, String)} to update the status of registered checks. + *

+ * If at least one check failed, the entire test is considered to have failed. + * + * @since 5.7.0 + */ +public final class ExtensionTestResult { + + private final Map checkByName = new LinkedHashMap<>(); + + private ExtensionTestResult(Collection checkNames) { + requireNonNull(checkNames, "checkNames must not be null"); + if (checkNames.isEmpty()) { + throw new IllegalArgumentException("checkNames must not be empty"); + } + for (final var checkName : checkNames) { + checkByName.put(checkName, new ExtensionTestCheck(checkName, Status.SKIPPED, null)); + } + } + + public static ExtensionTestResult ofChecks(String... checkNames) { + return new ExtensionTestResult(List.of(checkNames)); + } + + public ExtensionTestResult pass(String checkName) { + requireRegistered(checkName); + checkByName.put(checkName, new ExtensionTestCheck(checkName, Status.PASSED, null)); + return this; + } + + public ExtensionTestResult fail(String checkName, @Nullable String message) { + requireRegistered(checkName); + checkByName.put(checkName, new ExtensionTestCheck(checkName, Status.FAILED, message)); + return this; + } + + public List checks() { + return List.copyOf(checkByName.values()); + } + + public boolean isFailed() { + return checkByName.values().stream() + .map(ExtensionTestCheck::status) + .anyMatch(Status.FAILED::equals); + } + + private void requireRegistered(String checkName) { + if (!checkByName.containsKey(checkName)) { + throw new IllegalArgumentException( + "No check with name '%s' was registered".formatted(checkName)); + } + } + +} \ No newline at end of file diff --git a/vuln-data-source/nvd/pom.xml b/vuln-data-source/nvd/pom.xml index 48d3461b36..4faab7e7c7 100644 --- a/vuln-data-source/nvd/pom.xml +++ b/vuln-data-source/nvd/pom.xml @@ -101,6 +101,12 @@ io.github.nscuro versatile-core + + + org.wiremock + wiremock-standalone + test + diff --git a/vuln-data-source/nvd/src/main/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactory.java b/vuln-data-source/nvd/src/main/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactory.java index 5297f90dac..30743862f5 100644 --- a/vuln-data-source/nvd/src/main/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactory.java +++ b/vuln-data-source/nvd/src/main/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactory.java @@ -23,28 +23,42 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.dependencytrack.plugin.api.ExtensionContext; +import org.dependencytrack.plugin.api.ExtensionTestResult; import org.dependencytrack.plugin.api.config.ConfigRegistry; +import org.dependencytrack.plugin.api.config.RuntimeConfig; import org.dependencytrack.plugin.api.config.RuntimeConfigSpec; import org.dependencytrack.plugin.api.storage.ExtensionKVStore; import org.dependencytrack.vulndatasource.api.VulnDataSource; import org.dependencytrack.vulndatasource.api.VulnDataSourceFactory; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; +import java.net.InetAddress; import java.net.URI; +import java.net.UnknownHostException; import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; + +import static java.util.Objects.requireNonNull; /** * @since 5.7.0 */ +@NullMarked final class NvdVulnDataSourceFactory implements VulnDataSourceFactory { private static final Logger LOGGER = LoggerFactory.getLogger(NvdVulnDataSourceFactory.class); - private ConfigRegistry configRegistry; - private ExtensionKVStore kvStore; - private ObjectMapper objectMapper; - private HttpClient httpClient; + private @Nullable ConfigRegistry configRegistry; + private @Nullable ExtensionKVStore kvStore; + private @Nullable ObjectMapper objectMapper; + private @Nullable HttpClient httpClient; @Override public String extensionName() { @@ -100,6 +114,78 @@ public VulnDataSource create() { return new NvdVulnDataSource(watermarkManager, objectMapper, httpClient, config.getCveFeedsUrl().toString()); } + @Override + public ExtensionTestResult test(@Nullable RuntimeConfig runtimeConfig) { + requireNonNull(configRegistry, "configRegistry has not been initialized"); + requireNonNull(httpClient, "httpClient has not been initialized"); + + if (!(runtimeConfig instanceof final NvdVulnDataSourceConfig nvdConfig)) { + throw new IllegalArgumentException(); + } + + final var testResult = ExtensionTestResult.ofChecks("connection", "feed_format"); + + if (!nvdConfig.getEnabled()) { + return testResult; + } + + final URI feedsUrl = !nvdConfig.getCveFeedsUrl().getPath().endsWith("/") + ? URI.create(nvdConfig.getCveFeedsUrl().toString() + "/") + : nvdConfig.getCveFeedsUrl(); + final URI metadataUri = feedsUrl.resolve("json/cve/2.0/nvdcve-2.0-modified.meta"); + + if (!configRegistry + .getDeploymentConfig() + .getOptionalValue("allow-local-connections", boolean.class) + .orElse(false)) { + try { + final var hostAddress = InetAddress.getByName(feedsUrl.getHost()); + if (hostAddress.isLoopbackAddress() + || hostAddress.isLinkLocalAddress() + || hostAddress.isSiteLocalAddress() + || hostAddress.isAnyLocalAddress()) { + return testResult.fail("connection", "Connection to local hosts is not allowed"); + } + } catch (UnknownHostException e) { + return testResult.fail("connection", "Unknown host"); + } + } + + final HttpRequest request = HttpRequest.newBuilder() + .uri(metadataUri) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + + final HttpResponse response; + try { + response = httpClient.send(request, BodyHandlers.ofString()); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + LOGGER.warn("Failed to connect to {}", metadataUri, e); + return testResult.fail("connection", "Connection failed, check logs for details"); + } + + if (response.statusCode() != 200) { + LOGGER.warn("Unexpected response code {} from {}", response.statusCode(), metadataUri); + return testResult.fail("connection", "Unexpected response code, check logs for details"); + } + + testResult.pass("connection"); + + try { + final var ignored = NvdDataFeedMetadata.of(response.body()); + testResult.pass("feed_format"); + } catch (RuntimeException e) { + LOGGER.warn("Failed to parse feed metadata from {}", metadataUri, e); + testResult.fail("feed_format", "Failed to parse feed metadata, check logs for details"); + } + + return testResult; + } + @Override public void close() { if (httpClient != null) { diff --git a/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactoryTest.java b/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactoryTest.java index d199f438d8..32623d9fe6 100644 --- a/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactoryTest.java +++ b/vuln-data-source/nvd/src/test/java/org/dependencytrack/vulndatasource/nvd/NvdVulnDataSourceFactoryTest.java @@ -18,9 +18,27 @@ */ package org.dependencytrack.vulndatasource.nvd; +import com.github.tomakehurst.wiremock.http.Fault; +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import org.dependencytrack.plugin.api.ExtensionContext; +import org.dependencytrack.plugin.api.ExtensionTestCheck; +import org.dependencytrack.plugin.api.ExtensionTestResult; import org.dependencytrack.plugin.testing.AbstractExtensionFactoryTest; +import org.dependencytrack.plugin.testing.MockConfigRegistry; import org.dependencytrack.vulndatasource.api.VulnDataSource; import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Map; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; class NvdVulnDataSourceFactoryTest extends AbstractExtensionFactoryTest<@NonNull VulnDataSource, @NonNull NvdVulnDataSourceFactory> { @@ -28,4 +46,163 @@ protected NvdVulnDataSourceFactoryTest() { super(NvdVulnDataSourceFactory.class); } + @Nested + @WireMockTest + class TestMethodTest { + + @Test + void shouldPassConnectivityAndFeedFormatCheck(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlPathEqualTo("/json/cve/2.0/nvdcve-2.0-modified.meta")) + .willReturn(aResponse() + .withBody(""" + lastModifiedDate:2026-01-19T16:00:01-05:00 + size:15114674 + zipSize:1674794 + gzSize:1674650 + sha256:482399306951B6FF9E00E3EC72A7EED8D927FB2DB4F4E61F2D6218CF67133CC0 + """))); + + factory.init( + new ExtensionContext( + new MockConfigRegistry( + Map.of("allow-local-connections", "true")))); + + final var runtimeConfig = new NvdVulnDataSourceConfig() + .withEnabled(true) + .withCveFeedsUrl(URI.create(wmRuntimeInfo.getHttpBaseUrl())); + + final ExtensionTestResult testResult = factory.test(runtimeConfig); + + assertThat(testResult.isFailed()).isFalse(); + assertThat(testResult.checks()).satisfiesExactly( + check -> { + assertThat(check.name()).isEqualTo("connection"); + assertThat(check.status()).isEqualTo(ExtensionTestCheck.Status.PASSED); + assertThat(check.message()).isNull(); + }, + check -> { + assertThat(check.name()).isEqualTo("feed_format"); + assertThat(check.status()).isEqualTo(ExtensionTestCheck.Status.PASSED); + assertThat(check.message()).isNull(); + }); + } + + @Test + void shouldReportConnectionFailure(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlPathEqualTo("/json/cve/2.0/nvdcve-2.0-modified.meta")) + .willReturn(aResponse() + .withFault(Fault.CONNECTION_RESET_BY_PEER))); + + factory.init( + new ExtensionContext( + new MockConfigRegistry( + Map.of("allow-local-connections", "true")))); + + final var runtimeConfig = new NvdVulnDataSourceConfig() + .withEnabled(true) + .withCveFeedsUrl(URI.create(wmRuntimeInfo.getHttpBaseUrl())); + + final ExtensionTestResult testResult = factory.test(runtimeConfig); + + assertThat(testResult.isFailed()).isTrue(); + assertThat(testResult.checks()).satisfiesExactly( + check -> { + assertThat(check.name()).isEqualTo("connection"); + assertThat(check.status()).isEqualTo(ExtensionTestCheck.Status.FAILED); + assertThat(check.message()).isEqualTo("Connection failed, check logs for details"); + }, + check -> { + assertThat(check.name()).isEqualTo("feed_format"); + assertThat(check.status()).isEqualTo(ExtensionTestCheck.Status.SKIPPED); + assertThat(check.message()).isNull(); + }); + } + + @Test + void shouldReportConnectionFailureWhenLocalConnectionsAreDisallowed(WireMockRuntimeInfo wmRuntimeInfo) { + factory.init( + new ExtensionContext( + new MockConfigRegistry( + Map.of("allow-local-connections", "false")))); + + final var runtimeConfig = new NvdVulnDataSourceConfig() + .withEnabled(true) + .withCveFeedsUrl(URI.create(wmRuntimeInfo.getHttpBaseUrl())); + + final ExtensionTestResult testResult = factory.test(runtimeConfig); + + assertThat(testResult.isFailed()).isTrue(); + assertThat(testResult.checks()).satisfiesExactly( + check -> { + assertThat(check.name()).isEqualTo("connection"); + assertThat(check.status()).isEqualTo(ExtensionTestCheck.Status.FAILED); + assertThat(check.message()).isEqualTo("Connection to local hosts is not allowed"); + }, + check -> { + assertThat(check.name()).isEqualTo("feed_format"); + assertThat(check.status()).isEqualTo(ExtensionTestCheck.Status.SKIPPED); + assertThat(check.message()).isNull(); + }); + } + + @Test + void shouldReportInvalidFeedFormatFailure(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlPathEqualTo("/json/cve/2.0/nvdcve-2.0-modified.meta")) + .willReturn(aResponse() + .withBody("invalid"))); + + factory.init( + new ExtensionContext( + new MockConfigRegistry( + Map.of("allow-local-connections", "true")))); + + final var runtimeConfig = new NvdVulnDataSourceConfig() + .withEnabled(true) + .withCveFeedsUrl(URI.create(wmRuntimeInfo.getHttpBaseUrl())); + + final ExtensionTestResult testResult = factory.test(runtimeConfig); + + assertThat(testResult.isFailed()).isTrue(); + assertThat(testResult.checks()).satisfiesExactly( + check -> { + assertThat(check.name()).isEqualTo("connection"); + assertThat(check.status()).isEqualTo(ExtensionTestCheck.Status.PASSED); + assertThat(check.message()).isNull(); + }, + check -> { + assertThat(check.name()).isEqualTo("feed_format"); + assertThat(check.status()).isEqualTo(ExtensionTestCheck.Status.FAILED); + assertThat(check.message()).isEqualTo("Failed to parse feed metadata, check logs for details"); + }); + } + + @Test + void shouldReportAllChecksSkippedWhenDisabled(WireMockRuntimeInfo wmRuntimeInfo) { + factory.init( + new ExtensionContext( + new MockConfigRegistry( + Map.of("allow-local-connections", "true")))); + + final var runtimeConfig = new NvdVulnDataSourceConfig() + .withEnabled(false) + .withCveFeedsUrl(URI.create(wmRuntimeInfo.getHttpBaseUrl())); + + final ExtensionTestResult testResult = factory.test(runtimeConfig); + + assertThat(testResult.isFailed()).isFalse(); + assertThat(testResult.checks()).satisfiesExactly( + check -> { + assertThat(check.name()).isEqualTo("connection"); + assertThat(check.status()).isEqualTo(ExtensionTestCheck.Status.SKIPPED); + assertThat(check.message()).isNull(); + }, + check -> { + assertThat(check.name()).isEqualTo("feed_format"); + assertThat(check.status()).isEqualTo(ExtensionTestCheck.Status.SKIPPED); + assertThat(check.message()).isNull(); + }); + } + + } + } \ No newline at end of file