diff --git a/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt b/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt index dbceb671c6..25ed213355 100644 --- a/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt +++ b/api/v1/mapping/src/commonMain/kotlin/ApiMappings.kt @@ -78,7 +78,6 @@ import org.eclipse.apoapsis.ortserver.api.v1.model.ShortestDependencyPath as Api import org.eclipse.apoapsis.ortserver.api.v1.model.SourceCodeOrigin as ApiSourceCodeOrigin import org.eclipse.apoapsis.ortserver.api.v1.model.SubmoduleFetchStrategy as ApiSubmoduleFetchStrategy import org.eclipse.apoapsis.ortserver.api.v1.model.User as ApiUser -import org.eclipse.apoapsis.ortserver.api.v1.model.UserDisplayName as ApiUserDisplayName import org.eclipse.apoapsis.ortserver.api.v1.model.UserGroup as ApiUserGroup import org.eclipse.apoapsis.ortserver.api.v1.model.VcsInfo as ApiVcsInfo import org.eclipse.apoapsis.ortserver.api.v1.model.VcsInfoCurationData as ApiVcsInfoCurationData @@ -93,6 +92,7 @@ import org.eclipse.apoapsis.ortserver.model.AdvisorJob import org.eclipse.apoapsis.ortserver.model.AdvisorJobConfiguration import org.eclipse.apoapsis.ortserver.model.AnalyzerJob import org.eclipse.apoapsis.ortserver.model.AnalyzerJobConfiguration +import org.eclipse.apoapsis.ortserver.model.AppliedVulnerabilityResolution import org.eclipse.apoapsis.ortserver.model.ContentManagementSection import org.eclipse.apoapsis.ortserver.model.EcosystemStats import org.eclipse.apoapsis.ortserver.model.EnvironmentConfig @@ -126,7 +126,6 @@ import org.eclipse.apoapsis.ortserver.model.Severity import org.eclipse.apoapsis.ortserver.model.SourceCodeOrigin import org.eclipse.apoapsis.ortserver.model.SubmoduleFetchStrategy import org.eclipse.apoapsis.ortserver.model.User -import org.eclipse.apoapsis.ortserver.model.UserDisplayName import org.eclipse.apoapsis.ortserver.model.UserGroup import org.eclipse.apoapsis.ortserver.model.VulnerabilityFilters import org.eclipse.apoapsis.ortserver.model.VulnerabilityForRunsFilters @@ -393,7 +392,9 @@ fun OrtRun.mapToApi(jobs: ApiJobs) = resolvedJobConfigContext = resolvedJobConfigContext, environmentConfigPath = environmentConfigPath, traceId = traceId, - userDisplayName = userDisplayName?.mapToApi() + userDisplayName = userDisplayName?.mapToApi(), + outdated = outdated, + outdatedMessage = outdatedMessage ) fun OrtRun.mapToApiSummary(jobs: ApiJobSummaries) = @@ -414,7 +415,9 @@ fun OrtRun.mapToApiSummary(jobs: ApiJobSummaries) = jobConfigContext = jobConfigContext, resolvedJobConfigContext = resolvedJobConfigContext, environmentConfigPath = environmentConfigPath, - userDisplayName = userDisplayName?.mapToApi() + userDisplayName = userDisplayName?.mapToApi(), + outdated = outdated, + outdatedMessage = outdatedMessage ) fun OrtRunSummary.mapToApi() = @@ -595,12 +598,21 @@ fun AdvisorDetails.mapToApi() = ApiAdvisorDetails( capabilities = capabilities.map { ApiAdvisorCapability.valueOf(it.name) }.toSet() ) +fun AppliedVulnerabilityResolution.mapToApi() = + ApiVulnerabilityResolution( + externalId = resolution.externalId, + reason = resolution.reason, + comment = resolution.comment, + definition = definition?.mapToApi() + ) + fun VulnerabilityWithDetails.mapToApi() = ApiVulnerabilityWithDetails( vulnerability = vulnerability.mapToApi(), identifier = identifier.mapToApi(), rating = rating.mapToApi(), resolutions = resolutions.map { it.mapToApi() }, + newMatchingResolutionDefinitions = newMatchingResolutionDefinitions.map { it.mapToApi() }, advisor = advisor.mapToApi(), purl = purl ) @@ -842,8 +854,6 @@ fun Project.mapToApi() = ApiProject( scopeNames = scopeNames ) -fun UserDisplayName.mapToApi() = ApiUserDisplayName(username = username, fullName = fullName) - fun ContentManagementSection.mapToApi() = ApiContentManagementSection( id = id, isEnabled = isEnabled, diff --git a/api/v1/model/src/commonMain/kotlin/OrtRun.kt b/api/v1/model/src/commonMain/kotlin/OrtRun.kt index 3baa5e2d82..87d6a5837e 100644 --- a/api/v1/model/src/commonMain/kotlin/OrtRun.kt +++ b/api/v1/model/src/commonMain/kotlin/OrtRun.kt @@ -22,6 +22,8 @@ package org.eclipse.apoapsis.ortserver.api.v1.model import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName + @Serializable data class OrtRun( /** @@ -136,7 +138,17 @@ data class OrtRun( /** * The display name of the user that triggered the scan. */ - val userDisplayName: UserDisplayName? = null + val userDisplayName: UserDisplayName? = null, + + /** + * A flag to indicate if the results of the run are outdated, e.g. because of a new resolution. + */ + val outdated: Boolean = false, + + /** + * A message describing why the results of the run are outdated. + */ + val outdatedMessage: String? = null ) /** diff --git a/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt b/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt index 11f5370886..802a6148dc 100644 --- a/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt +++ b/api/v1/model/src/commonMain/kotlin/OrtRunSummary.kt @@ -22,6 +22,8 @@ package org.eclipse.apoapsis.ortserver.api.v1.model import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName + /** * The summary of an ORT run. */ @@ -116,7 +118,17 @@ data class OrtRunSummary( /** * The display name of the user that triggered this run. */ - val userDisplayName: UserDisplayName? = null + val userDisplayName: UserDisplayName? = null, + + /** + * A flag to indicate if the results of the run are outdated, e.g. because of a new resolution. + */ + val outdated: Boolean = false, + + /** + * A message describing why the results of the run are outdated. + */ + val outdatedMessage: String? = null ) /** diff --git a/api/v1/model/src/commonMain/kotlin/VulnerabilityResolution.kt b/api/v1/model/src/commonMain/kotlin/VulnerabilityResolution.kt index 21d256d554..3a2f865f6c 100644 --- a/api/v1/model/src/commonMain/kotlin/VulnerabilityResolution.kt +++ b/api/v1/model/src/commonMain/kotlin/VulnerabilityResolution.kt @@ -21,6 +21,8 @@ package org.eclipse.apoapsis.ortserver.api.v1.model import kotlinx.serialization.Serializable +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition + /** * Defines the resolution of a Vulnerability. This can be used to silence false positives, or vulnerabilities that * have been identified as not being relevant. @@ -29,5 +31,8 @@ import kotlinx.serialization.Serializable data class VulnerabilityResolution( val externalId: String, val reason: String, - val comment: String + val comment: String, + + /** The definition of the [VulnerabilityResolution], if available. */ + val definition: VulnerabilityResolutionDefinition? = null ) diff --git a/api/v1/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt b/api/v1/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt index 588ed05d41..db37330cb9 100644 --- a/api/v1/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt +++ b/api/v1/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt @@ -21,6 +21,8 @@ package org.eclipse.apoapsis.ortserver.api.v1.model import kotlinx.serialization.Serializable +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition + /** * A data class to gather information and related data about a [Vulnerability]. */ @@ -34,6 +36,9 @@ data class VulnerabilityWithDetails( val resolutions: List = emptyList(), + /** The resolution definitions that match this vulnerability but were not yet available during the run. */ + val newMatchingResolutionDefinitions: List = emptyList(), + /** Details of the used advisor. */ val advisor: AdvisorDetails, diff --git a/components/resolutions/api-model/build.gradle.kts b/components/resolutions/api-model/build.gradle.kts new file mode 100644 index 0000000000..1779dbd507 --- /dev/null +++ b/components/resolutions/api-model/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +plugins { + id("ort-server-kotlin-multiplatform-conventions") + id("ort-server-publication-conventions") + + // Apply third-party plugins. + alias(libs.plugins.kotlinSerialization) +} + +group = "org.eclipse.apoapsis.ortserver.components.resolutions" + +kotlin { + linuxX64() + macosArm64() + macosX64() + mingwX64() + + sourceSets { + commonMain { + dependencies { + api(projects.shared.apiModel) + + implementation(libs.kotlinxSerializationJson) + } + } + } +} diff --git a/components/resolutions/api-model/src/commonMain/kotlin/PatchVulnerabilityResolution.kt b/components/resolutions/api-model/src/commonMain/kotlin/PatchVulnerabilityResolution.kt new file mode 100644 index 0000000000..c5b7a5ef72 --- /dev/null +++ b/components/resolutions/api-model/src/commonMain/kotlin/PatchVulnerabilityResolution.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import kotlinx.serialization.Serializable + +import org.eclipse.apoapsis.ortserver.shared.apimodel.OptionalValue +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason + +/** + * The request object for updating a vulnerability resolution. + */ +@Serializable +data class PatchVulnerabilityResolution( + /** + * The list of vulnerability ID matchers (regular expressions) to match the ids of the vulnerabilities to resolve. + */ + val idMatchers: OptionalValue> = OptionalValue.Absent, + + /** The reason why the vulnerability is resolved. */ + val reason: OptionalValue = OptionalValue.Absent, + + /** A comment to further explain why the [reason] is applicable here. */ + val comment: OptionalValue = OptionalValue.Absent +) diff --git a/components/resolutions/api-model/src/commonMain/kotlin/PostVulnerabilityResolution.kt b/components/resolutions/api-model/src/commonMain/kotlin/PostVulnerabilityResolution.kt new file mode 100644 index 0000000000..74a356461e --- /dev/null +++ b/components/resolutions/api-model/src/commonMain/kotlin/PostVulnerabilityResolution.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import kotlinx.serialization.Serializable + +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason + +/** + * The request object for creating a vulnerability resolution. + */ +@Serializable +data class PostVulnerabilityResolution( + /** The ID of the run in which context the vulnerability resolution is made in. */ + val contextRunId: Long, + + /** + * The list of vulnerability ID matchers (regular expressions) to match the ids of the vulnerabilities to resolve. + */ + val idMatchers: List, + + /** The reason why the vulnerability is resolved. */ + val reason: VulnerabilityResolutionReason, + + /** A comment to further explain why the [reason] is applicable here. */ + val comment: String +) diff --git a/components/resolutions/backend/build.gradle.kts b/components/resolutions/backend/build.gradle.kts new file mode 100644 index 0000000000..5c3d5783b9 --- /dev/null +++ b/components/resolutions/backend/build.gradle.kts @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +plugins { + id("ort-server-kotlin-component-backend-conventions") + id("ort-server-publication-conventions") +} + +group = "org.eclipse.apoapsis.ortserver.components.resolutions" + +dependencies { + api(projects.model) + api(projects.services.ortRunService) + + api(libs.exposedCore) + + implementation(projects.dao) + + routesImplementation(projects.components.authorizationKeycloak.backend) + routesImplementation(projects.components.resolutions.apiModel) + routesImplementation(projects.shared.apiMappings) + routesImplementation(projects.shared.apiModel) + routesImplementation(projects.shared.ktorUtils) + + routesImplementation(ktorLibs.http) + routesImplementation(ktorLibs.server.auth) + routesImplementation(ktorLibs.server.core) + routesImplementation(libs.kotlinxDatetime) + routesImplementation(libs.ktorOpenApi) + + testImplementation(testFixtures(projects.dao)) + testImplementation(testFixtures(projects.shared.ktorUtils)) + + testImplementation(ktorLibs.client.core) + testImplementation(ktorLibs.http) + testImplementation(ktorLibs.server.auth) + testImplementation(ktorLibs.server.core) + testImplementation(ktorLibs.server.testHost) + testImplementation(libs.kotestAssertionsCore) + testImplementation(libs.kotestAssertionsKtor) + testImplementation(libs.kotestFrameworkEngine) + testImplementation(libs.mockk) +} diff --git a/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt b/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt new file mode 100644 index 0000000000..4459285dcc --- /dev/null +++ b/components/resolutions/backend/src/main/kotlin/VulnerabilityResolutionDefinitionService.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import org.eclipse.apoapsis.ortserver.dao.dbQuery +import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.RepositoryConfigurationsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.RepositoryConfigurationsVulnerabilityResolutionsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.userDisplayName.UserDisplayNameDao +import org.eclipse.apoapsis.ortserver.dao.tables.ChangeLogTable +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable +import org.eclipse.apoapsis.ortserver.model.ChangeEventAction +import org.eclipse.apoapsis.ortserver.model.ChangeEventEntityType +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.model.UserDisplayName +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.model.util.OptionalValue +import org.eclipse.apoapsis.ortserver.model.util.asPresent +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService + +import org.jetbrains.exposed.sql.Database + +/** + * Service class for managing vulnerability resolution definitions. + */ +class VulnerabilityResolutionDefinitionService(private val db: Database, private val ortRunService: OrtRunService) { + suspend fun create( + hierarchyId: RepositoryId, + contextRunId: Long, + userDisplayName: UserDisplayName, + idMatchers: List, + reason: VulnerabilityResolutionReason, + comment: String + ): VulnerabilityResolutionDefinition = db.dbQuery { + val id = VulnerabilityResolutionDefinitionsTable.insert( + hierarchyId, + contextRunId, + idMatchers, + reason, + comment + ) + + addChangeLogEvent(id, ChangeEventAction.CREATE, userDisplayName) + + ortRunService.markAsOutdated(listOf(contextRunId), "New vulnerability resolution added.") + + VulnerabilityResolutionDefinitionsTable.get(id) + } + + suspend fun archive(id: Long, userDisplayName: UserDisplayName): VulnerabilityResolutionDefinition = db.dbQuery { + VulnerabilityResolutionDefinitionsTable.updateDefinition( + id, + archivedInput = true.asPresent() + ) + + addChangeLogEvent(id, ChangeEventAction.ARCHIVE, userDisplayName) + + val definition = VulnerabilityResolutionDefinitionsTable.get(id) + + ortRunService.markAsOutdated( + getAffectedRuns(id) + definition.contextRunId, + "Vulnerability resolution definition archived." + ) + + definition + } + + suspend fun restore(id: Long, userDisplayName: UserDisplayName): VulnerabilityResolutionDefinition = db.dbQuery { + VulnerabilityResolutionDefinitionsTable.updateDefinition( + id, + archivedInput = false.asPresent() + ) + + addChangeLogEvent(id, ChangeEventAction.RESTORE, userDisplayName) + + val definition = VulnerabilityResolutionDefinitionsTable.get(id) + + ortRunService.markAsOutdated( + getAffectedRuns(id) + definition.contextRunId, + "Vulnerability resolution definition restored." + ) + + definition + } + + suspend fun update( + id: Long, + userDisplayName: UserDisplayName, + idMatchers: OptionalValue> = OptionalValue.Absent, + reason: OptionalValue = OptionalValue.Absent, + comment: OptionalValue = OptionalValue.Absent + ): VulnerabilityResolutionDefinition = db.dbQuery { + VulnerabilityResolutionDefinitionsTable.updateDefinition( + id, + idMatchers, + reason, + comment + ) + + addChangeLogEvent(id, ChangeEventAction.UPDATE, userDisplayName) + + val definition = VulnerabilityResolutionDefinitionsTable.get(id) + + ortRunService.markAsOutdated( + getAffectedRuns(id) + definition.contextRunId, + "Vulnerability resolution definition updated." + ) + + definition + } + + suspend fun getById(id: Long): VulnerabilityResolutionDefinition? = db.dbQuery { + VulnerabilityResolutionDefinitionsTable + .getOrNull(id) + } + + private fun addChangeLogEvent( + entityId: Long, + action: ChangeEventAction, + userDisplayName: UserDisplayName + ) { + val user = + UserDisplayNameDao.insertOrUpdate(userDisplayName) ?: throw NullPointerException("No user created or found") + + ChangeLogTable.insert( + ChangeEventEntityType.VULNERABILITY_RESOLUTION_DEFINITION, + entityId.toString(), + user.id.value, + action + ) + } + + private fun getAffectedRuns( + definitionId: Long + ): List { + return RepositoryConfigurationsVulnerabilityResolutionsTable + .innerJoin(RepositoryConfigurationsTable) + .select(RepositoryConfigurationsTable.ortRunId) + .where { + RepositoryConfigurationsVulnerabilityResolutionsTable.vulnerabilityResolutionDefinitionId eq + definitionId + } + .map { it[RepositoryConfigurationsTable.ortRunId].value } + } +} diff --git a/components/resolutions/backend/src/routes/kotlin/Routing.kt b/components/resolutions/backend/src/routes/kotlin/Routing.kt new file mode 100644 index 0000000000..ef0bc112bf --- /dev/null +++ b/components/resolutions/backend/src/routes/kotlin/Routing.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import io.ktor.server.routing.Route + +import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.deleteVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.patchVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.postVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities.restoreVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService + +/** Add all resolutions routes. */ +fun Route.resolutionsRoutes( + ortRunService: OrtRunService, + vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService +) { + postVulnerabilityResolution(ortRunService, vulnerabilityResolutionDefinitionService) + deleteVulnerabilityResolution(vulnerabilityResolutionDefinitionService) + restoreVulnerabilityResolution(vulnerabilityResolutionDefinitionService) + patchVulnerabilityResolution(vulnerabilityResolutionDefinitionService) +} diff --git a/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolution.kt b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolution.kt new file mode 100644 index 0000000000..4b33dd963f --- /dev/null +++ b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolution.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.github.smiley4.ktoropenapi.delete + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.principal +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +import kotlinx.datetime.Instant + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.AuthorizationException +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getFullName +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUserId +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUsername +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService +import org.eclipse.apoapsis.ortserver.model.UserDisplayName as ModelUserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToApi +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody +import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter +import org.eclipse.apoapsis.ortserver.shared.ktorutils.respondError + +internal fun Route.deleteVulnerabilityResolution( + vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService +) = delete("/resolutions/vulnerabilities/{id}", { + operationId = "deleteVulnerabilityResolution" + summary = "Delete a vulnerability resolution" + tags = listOf("Resolutions") + + request { + pathParameter("id") { + description = "The ID of the vulnerability resolution definition" + } + } + + response { + HttpStatusCode.OK to { + description = "Success" + + jsonBody { + example("Delete Vulnerability Resolution") { + value = VulnerabilityResolutionDefinition( + id = 1, + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY, + comment = "Comment", + archived = true, + changes = listOf( + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-01T00:00:00Z"), + ChangeEventAction.CREATE + ), + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-02T00:00:00Z"), + ChangeEventAction.ARCHIVE + ) + ) + ) + } + } + } + + HttpStatusCode.NoContent to { + description = "The vulnerability resolution was already archived." + } + } +}) { + val id = call.requireIdParameter("id") + + val definition = vulnerabilityResolutionDefinitionService.getById(id) ?: throw AuthorizationException() + + requirePermission(RepositoryPermission.WRITE.roleName(definition.hierarchyId.value)) + + if (definition.archived) { + call.respond(HttpStatusCode.NoContent, "The vulnerability resolution was already archived.") + return@delete + } + + // Extract the user information from the principal. + val userDisplayName = call.principal()?.let { principal -> + ModelUserDisplayName(principal.getUserId(), principal.getUsername(), principal.getFullName()) + } + + if (userDisplayName == null) { + call.respondError(HttpStatusCode.InternalServerError, "Unable to resolve user display name from token.") + return@delete + } + + val archivedDefinition = vulnerabilityResolutionDefinitionService.archive(id, userDisplayName).mapToApi() + + call.respond(HttpStatusCode.OK, archivedDefinition) +} diff --git a/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PatchVulnerabilityResolution.kt b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PatchVulnerabilityResolution.kt new file mode 100644 index 0000000000..0e8c7f2334 --- /dev/null +++ b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PatchVulnerabilityResolution.kt @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.github.smiley4.ktoropenapi.patch + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.principal +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +import kotlinx.datetime.Instant + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.AuthorizationException +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getFullName +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUserId +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUsername +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission +import org.eclipse.apoapsis.ortserver.components.resolutions.PatchVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService +import org.eclipse.apoapsis.ortserver.model.UserDisplayName as ModelUserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToApi +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToModel +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent +import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody +import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter +import org.eclipse.apoapsis.ortserver.shared.ktorutils.respondError + +internal fun Route.patchVulnerabilityResolution( + vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService +) = patch("/resolutions/vulnerabilities/{id}", { + operationId = "patchVulnerabilityResolution" + summary = "Update a vulnerability resolution" + tags = listOf("Resolutions") + + request { + pathParameter("id") { + description = "The ID of the vulnerability resolution definition" + } + + jsonBody { + description = "Set the values that should be updated." + example("Update Vulnerability Resolution") { + value = PatchVulnerabilityResolution( + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp").asPresent(), + reason = VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY.asPresent(), + comment = "Updated comment.".asPresent() + ) + } + } + } + + response { + HttpStatusCode.OK to { + description = "Success" + + jsonBody { + example("Update Vulnerability Resolution") { + value = VulnerabilityResolutionDefinition( + id = 1, + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY, + comment = "Updated comment.", + archived = false, + changes = listOf( + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-01T00:00:00Z"), + action = ChangeEventAction.CREATE + ), + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-02T00:00:00Z"), + action = ChangeEventAction.UPDATE + ) + ) + ) + } + } + } + + HttpStatusCode.BadRequest to { + description = "The requested vulnerability resolution is archived." + } + } +}) { + val id = call.requireIdParameter("id") + + val definition = vulnerabilityResolutionDefinitionService.getById(id) ?: throw AuthorizationException() + + requirePermission(RepositoryPermission.WRITE.roleName(definition.hierarchyId.value)) + + if (definition.archived) { + call.respondError(HttpStatusCode.Conflict, "The requested vulnerability resolution is archived.") + return@patch + } + + val updateResolution = call.receive() + + // Extract the user information from the principal. + val userDisplayName = call.principal()?.let { principal -> + ModelUserDisplayName(principal.getUserId(), principal.getUsername(), principal.getFullName()) + } + + if (userDisplayName == null) { + call.respondError(HttpStatusCode.InternalServerError, "Unable to resolve user display name from token.") + return@patch + } + + val updatedDefinition = vulnerabilityResolutionDefinitionService.update( + id, + userDisplayName, + updateResolution.idMatchers.mapToModel(), + updateResolution.reason.mapToModel { it.mapToModel() }, + updateResolution.comment.mapToModel() + ).mapToApi() + + call.respond(HttpStatusCode.OK, updatedDefinition) +} diff --git a/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PostVulnerabilityResolution.kt b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PostVulnerabilityResolution.kt new file mode 100644 index 0000000000..f8783f7c2c --- /dev/null +++ b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/PostVulnerabilityResolution.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.github.smiley4.ktoropenapi.post + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.principal +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +import kotlinx.datetime.Instant + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.AuthorizationException +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getFullName +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUserId +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUsername +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.model.UserDisplayName as ModelUserDisplayName +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToApi +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToModel +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody +import org.eclipse.apoapsis.ortserver.shared.ktorutils.respondError + +internal fun Route.postVulnerabilityResolution( + ortRunService: OrtRunService, + vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService +) = post("/resolutions/vulnerabilities", { + operationId = "postVulnerabilityResolution" + summary = "Create a vulnerability resolution" + tags = listOf("Resolutions") + + request { + jsonBody { + example("Create Vulnerability Resolution") { + value = PostVulnerabilityResolution( + contextRunId = 1, + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + comment = "Comment" + ) + } + } + } + + response { + HttpStatusCode.Created to { + description = "Success" + jsonBody { + example("Create Vulnerability Resolution") { + value = VulnerabilityResolutionDefinition( + id = 1, + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + comment = "Comment", + archived = false, + changes = listOf( + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-01T00:00:00Z"), + ChangeEventAction.CREATE + ) + ) + ) + } + } + } + } +}) { + val createResolution = call.receive() + + val repositoryId = ortRunService.getRepositoryIdForOrtRun(createResolution.contextRunId) + ?: throw AuthorizationException() + + requirePermission(RepositoryPermission.WRITE.roleName(repositoryId)) + + // Extract the user information from the principal. + val userDisplayName = call.principal()?.let { principal -> + ModelUserDisplayName(principal.getUserId(), principal.getUsername(), principal.getFullName()) + } + + if (userDisplayName == null) { + call.respondError(HttpStatusCode.InternalServerError, "Unable to resolve user display name from token.") + return@post + } + + val vulnerabilityResolutionDefinition = vulnerabilityResolutionDefinitionService.create( + RepositoryId(repositoryId), + createResolution.contextRunId, + userDisplayName, + createResolution.idMatchers, + createResolution.reason.mapToModel(), + createResolution.comment + ).mapToApi() + + call.respond(HttpStatusCode.Created, vulnerabilityResolutionDefinition) +} diff --git a/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolution.kt b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolution.kt new file mode 100644 index 0000000000..12a6ddc169 --- /dev/null +++ b/components/resolutions/backend/src/routes/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolution.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.github.smiley4.ktoropenapi.post + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.principal +import io.ktor.server.response.respond +import io.ktor.server.routing.Route + +import kotlinx.datetime.Instant + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.AuthorizationException +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getFullName +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUserId +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.getUsername +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.requirePermission +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService +import org.eclipse.apoapsis.ortserver.model.UserDisplayName as ModelUserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToApi +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody +import org.eclipse.apoapsis.ortserver.shared.ktorutils.requireIdParameter +import org.eclipse.apoapsis.ortserver.shared.ktorutils.respondError + +internal fun Route.restoreVulnerabilityResolution( + vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService +) = post("/resolutions/vulnerabilities/{id}/restore", { + operationId = "restoreVulnerabilityResolution" + summary = "Restore a vulnerability resolution" + tags = listOf("Resolutions") + + request { + pathParameter("id") { + description = "The ID of the vulnerability resolution definition." + } + } + + response { + HttpStatusCode.OK to { + description = "Success" + jsonBody { + example("Restore Vulnerability Resolution") { + value = VulnerabilityResolutionDefinition( + id = 1, + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + comment = "Comment", + archived = false, + changes = listOf( + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-01T00:00:00Z"), + ChangeEventAction.CREATE + ), + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-03T00:00:00Z"), + ChangeEventAction.ARCHIVE + ), + ChangeEvent( + user = UserDisplayName(username = "User"), + occurredAt = Instant.parse("2024-01-04T00:00:00Z"), + ChangeEventAction.RESTORE + ) + ) + ) + } + } + } + + HttpStatusCode.NoContent to { + description = "The vulnerability resolution is not archived." + } + } +}) { + val id = call.requireIdParameter("id") + + val definition = vulnerabilityResolutionDefinitionService.getById(id) ?: throw AuthorizationException() + + requirePermission(RepositoryPermission.WRITE.roleName(definition.hierarchyId.value)) + + if (!definition.archived) { + call.respond(HttpStatusCode.NoContent, "The vulnerability resolution is not archived.") + return@post + } + + // Extract the user information from the principal. + val userDisplayName = call.principal()?.let { principal -> + ModelUserDisplayName(principal.getUserId(), principal.getUsername(), principal.getFullName()) + } + + if (userDisplayName == null) { + call.respondError(HttpStatusCode.InternalServerError, "Unable to resolve user display name from token.") + return@post + } + + val vulnerabilityResolutionDefinition = + vulnerabilityResolutionDefinitionService.restore(id, userDisplayName).mapToApi() + + call.respond(HttpStatusCode.OK, vulnerabilityResolutionDefinition) +} diff --git a/components/resolutions/backend/src/test/kotlin/ResolutionsIntegrationTest.kt b/components/resolutions/backend/src/test/kotlin/ResolutionsIntegrationTest.kt new file mode 100644 index 0000000000..6dc90c791a --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/ResolutionsIntegrationTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import com.auth0.jwt.JWT + +import io.ktor.client.HttpClient +import io.ktor.server.application.createRouteScopedPlugin +import io.ktor.server.auth.authentication +import io.ktor.server.auth.principal +import io.ktor.server.testing.ApplicationTestBuilder + +import io.mockk.mockk + +import java.util.Base64 + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.OrtPrincipal +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.roles.Superuser +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService +import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractIntegrationTest + +import org.jetbrains.exposed.sql.Database + +@Suppress("UnnecessaryAbstractClass") +abstract class ResolutionsIntegrationTest(body: ResolutionsIntegrationTest.() -> Unit) : AbstractIntegrationTest({}) { + lateinit var ortRunService: OrtRunService + lateinit var vulnerabilityResolutionDefinitionService: VulnerabilityResolutionDefinitionService + + private lateinit var db: Database + private lateinit var fixtures: Fixtures + + init { + beforeEach { + db = dbExtension.db + fixtures = dbExtension.fixtures + + ortRunService = OrtRunService( + db, + fixtures.advisorJobRepository, + fixtures.advisorRunRepository, + fixtures.analyzerJobRepository, + fixtures.analyzerRunRepository, + fixtures.evaluatorJobRepository, + fixtures.evaluatorRunRepository, + fixtures.ortRunRepository, + fixtures.reporterJobRepository, + fixtures.reporterRunRepository, + fixtures.notifierJobRepository, + fixtures.notifierRunRepository, + fixtures.repositoryConfigurationRepository, + fixtures.repositoryRepository, + fixtures.resolvedConfigurationRepository, + fixtures.scannerJobRepository, + fixtures.scannerRunRepository, + mockk(), + mockk() + ) + + vulnerabilityResolutionDefinitionService = VulnerabilityResolutionDefinitionService(db, ortRunService) + } + + body() + } + + fun resolutionsTestApplication( + block: suspend ApplicationTestBuilder.(client: HttpClient) -> Unit + ) = integrationTestApplication( + routes = { + // Define a route-scoped plugin that injects a principal for tests + val injectTestPrincipal = createRouteScopedPlugin(name = "InjectTestPrincipal") { + onCall { call -> + if (call.principal() == null) { + val headerJson = """{"alg":"none","typ":"JWT"}""" + val payloadJson = """ + { + "sub": "user-1", + "preferred_username": "test", + "name": "Test User" + } + """.trimIndent() + + fun b64url(s: String) = + Base64.getUrlEncoder().withoutPadding() + .encodeToString(s.toByteArray(Charsets.UTF_8)) + + val token = "${b64url(headerJson)}.${b64url(payloadJson)}." + val decoded = JWT.decode(token) + + val principal = OrtPrincipal( + payload = decoded, + roles = setOf(Superuser.ROLE_NAME) + ) + + call.authentication.principal(principal) + } + } + } + + install(injectTestPrincipal) + + resolutionsRoutes(ortRunService, vulnerabilityResolutionDefinitionService) + }, + block = block + ) +} diff --git a/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt b/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt new file mode 100644 index 0000000000..cd926dfe5f --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/VulnerabilityResolutionDefinitionServiceTest.kt @@ -0,0 +1,410 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions + +import io.kotest.core.spec.style.WordSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe + +import io.mockk.mockk + +import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.model.ChangeEventAction +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.model.UserDisplayName +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.model.util.asPresent +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService + +import org.jetbrains.exposed.sql.Database + +class VulnerabilityResolutionDefinitionServiceTest : WordSpec({ + val dbExtension = extension(DatabaseTestExtension()) + + var repositoryId = 0L + var runId = 0L + + lateinit var db: Database + lateinit var fixtures: Fixtures + lateinit var ortRunService: OrtRunService + lateinit var definitionService: VulnerabilityResolutionDefinitionService + + beforeEach { + db = dbExtension.db + fixtures = dbExtension.fixtures + + repositoryId = fixtures.repository.id + runId = fixtures.ortRun.id + + ortRunService = OrtRunService( + db, + fixtures.advisorJobRepository, + fixtures.advisorRunRepository, + fixtures.analyzerJobRepository, + fixtures.analyzerRunRepository, + fixtures.evaluatorJobRepository, + fixtures.evaluatorRunRepository, + fixtures.ortRunRepository, + fixtures.reporterJobRepository, + fixtures.reporterRunRepository, + fixtures.notifierJobRepository, + fixtures.notifierRunRepository, + fixtures.repositoryConfigurationRepository, + fixtures.repositoryRepository, + fixtures.resolvedConfigurationRepository, + fixtures.scannerJobRepository, + fixtures.scannerRunRepository, + mockk(), + mockk() + ) + + definitionService = VulnerabilityResolutionDefinitionService(db, ortRunService) + } + + "create" should { + "add a new vulnerability resolution definition and an event in the change log" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definition = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + + with(definition) { + idMatchers shouldBe listOf("CVE-2020-15250") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Comment." + archived shouldBe false + changes shouldHaveSize 1 + + with(changes.first()) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.CREATE + } + } + } + + "mark the given context run as outdated" { + definitionService.create( + RepositoryId(repositoryId), + runId, + UserDisplayName( + "abc", + "Test", + "Test User" + ), + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + + val run = ortRunService.getOrtRun(runId) + + run shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "New vulnerability resolution added." + } + } + } + + "archive" should { + "archive a vulnerability resolution definition" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ).id + + val archivedDefinition = definitionService.archive(definitionId, userDisplayName) + + with(archivedDefinition) { + archived shouldBe true + changes shouldHaveSize 2 + + with(changes.first()) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.CREATE + } + + with(changes.last()) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.ARCHIVE + } + } + } + + "mark the affected runs as outdated" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ).id + + val run2Id = fixtures.createOrtRun().id + + val repositoryConfiguration = fixtures.createRepositoryConfiguration(run2Id) + fixtures.resolvedConfigurationRepository.addResolutions(run2Id, repositoryConfiguration.resolutions) + + val run3Id = fixtures.createOrtRun().id + + definitionService.archive(definitionId, userDisplayName) + + ortRunService.getOrtRun(runId).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition archived." + } + + ortRunService.getOrtRun(run2Id).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition archived." + } + + ortRunService.getOrtRun(run3Id).shouldNotBeNull { + outdated shouldBe false + outdatedMessage.shouldBeNull() + } + } + } + + "restore" should { + "restore an archived vulnerability resolution definition" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ).id + + definitionService.archive(definitionId, userDisplayName) + + val restoredDefinition = definitionService.restore(definitionId, userDisplayName) + + with(restoredDefinition) { + archived shouldBe false + changes shouldHaveSize 3 + + with(changes[0]) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.CREATE + } + + with(changes[1]) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.ARCHIVE + } + + with(changes[2]) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.RESTORE + } + } + } + + "mark the affected runs as outdated" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ).id + + val run2Id = fixtures.createOrtRun().id + + val repositoryConfiguration = fixtures.createRepositoryConfiguration(run2Id) + fixtures.resolvedConfigurationRepository.addResolutions(run2Id, repositoryConfiguration.resolutions) + + val run3Id = fixtures.createOrtRun().id + + definitionService.archive(definitionId, userDisplayName) + definitionService.restore(definitionId, userDisplayName) + + ortRunService.getOrtRun(runId).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition restored." + } + + ortRunService.getOrtRun(run2Id).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition restored." + } + + ortRunService.getOrtRun(run3Id).shouldNotBeNull { + outdated shouldBe false + outdatedMessage.shouldBeNull() + } + } + } + + "update" should { + "update a vulnerability resolution definition and add an event in the change log" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY, + "Comment." + ).id + + val updatedDefinition = definitionService.update( + definitionId, + userDisplayName, + listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp").asPresent(), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY.asPresent(), + "Updated comment.".asPresent() + ) + + with(updatedDefinition) { + idMatchers shouldBe listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Updated comment." + archived shouldBe false + changes shouldHaveSize 2 + + with(changes.first()) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.CREATE + } + + with(changes.last()) { + user shouldBe userDisplayName + action shouldBe ChangeEventAction.UPDATE + } + } + } + + "mark the affected runs as outdated" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ).id + + val run2Id = fixtures.createOrtRun().id + + val repositoryConfiguration = fixtures.createRepositoryConfiguration(run2Id) + fixtures.resolvedConfigurationRepository.addResolutions(run2Id, repositoryConfiguration.resolutions) + + val run3Id = fixtures.createOrtRun().id + + definitionService.update(definitionId, userDisplayName, comment = "Updated comment.".asPresent()) + + ortRunService.getOrtRun(runId).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition updated." + } + + ortRunService.getOrtRun(run2Id).shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe "Vulnerability resolution definition updated." + } + + ortRunService.getOrtRun(run3Id).shouldNotBeNull { + outdated shouldBe false + outdatedMessage.shouldBeNull() + } + } + } + + "getById" should { + "return the vulnerability resolution definition if it exists" { + val userDisplayName = UserDisplayName( + "abc", + "Test", + "Test User" + ) + + val definition = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + + definitionService.getById(definition.id) shouldBe definition + } + + "return null if the vulnerability resolution definition doesn't exist" { + definitionService.getById(1) shouldBe null + } + } +}) diff --git a/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt new file mode 100644 index 0000000000..3d7634e4af --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/routes/ResolutionsAuthorizationTest.kt @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes + +import io.ktor.client.request.delete +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode + +import io.mockk.mockk + +import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.permissions.RepositoryPermission +import org.eclipse.apoapsis.ortserver.components.resolutions.PatchVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService +import org.eclipse.apoapsis.ortserver.components.resolutions.resolutionsRoutes +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.model.UserDisplayName +import org.eclipse.apoapsis.ortserver.services.ortrun.OrtRunService +import org.eclipse.apoapsis.ortserver.shared.apimappings.mapToModel +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent +import org.eclipse.apoapsis.ortserver.shared.ktorutils.AbstractAuthorizationTest + +import org.jetbrains.exposed.sql.Database + +class ResolutionsAuthorizationTest : AbstractAuthorizationTest({ + var repositoryId = 0L + var runId = 0L + + val nonExistentRunId = 999L + + lateinit var createBody: PostVulnerabilityResolution + + lateinit var ortRunService: OrtRunService + lateinit var definitionService: VulnerabilityResolutionDefinitionService + + lateinit var db: Database + lateinit var fixtures: Fixtures + + beforeEach { + db = dbExtension.db + fixtures = dbExtension.fixtures + + repositoryId = fixtures.repository.id + runId = fixtures.ortRun.id + + authorizationService.ensureSuperuserAndSynchronizeRolesAndPermissions() + + createBody = PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + + ortRunService = OrtRunService( + db, + fixtures.advisorJobRepository, + fixtures.advisorRunRepository, + fixtures.analyzerJobRepository, + fixtures.analyzerRunRepository, + fixtures.evaluatorJobRepository, + fixtures.evaluatorRunRepository, + fixtures.ortRunRepository, + fixtures.reporterJobRepository, + fixtures.reporterRunRepository, + fixtures.notifierJobRepository, + fixtures.notifierRunRepository, + fixtures.repositoryConfigurationRepository, + fixtures.repositoryRepository, + fixtures.resolvedConfigurationRepository, + fixtures.scannerJobRepository, + fixtures.scannerRunRepository, + mockk(), + mockk() + ) + + definitionService = VulnerabilityResolutionDefinitionService(db, ortRunService) + } + + "PostVulnerabilityResolution" should { + "require role RepositoryPermission.WRITE.roleName(repositoryId)" { + requestShouldRequireRole( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + role = RepositoryPermission.WRITE.roleName(repositoryId), + successStatus = HttpStatusCode.Created + ) { + post("/resolutions/vulnerabilities") { + setBody(createBody) + } + } + } + + "respond with 'Forbidden' when repository ID cannot be resolved" { + requestShouldRequireAuthentication( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + successStatus = HttpStatusCode.Forbidden + ) { + post("/resolutions/vulnerabilities") { + setBody(createBody.copy(contextRunId = nonExistentRunId)) + } + } + } + } + + "DeleteVulnerabilityResolution" should { + "require role RepositoryPermission.WRITE.roleName(repositoryId)" { + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + UserDisplayName("abc", "Test"), + createBody.idMatchers, + createBody.reason.mapToModel(), + createBody.comment + ).id + + requestShouldRequireRole( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + role = RepositoryPermission.WRITE.roleName(repositoryId) + ) { + delete("/resolutions/vulnerabilities/$definitionId") + } + } + + "respond with 'Forbidden' when repository id cannot be resolved" { + requestShouldRequireAuthentication( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + successStatus = HttpStatusCode.Forbidden + ) { + delete("/resolutions/vulnerabilities/9999") + } + } + } + + "RestoreVulnerabilityResolution" should { + "require role RepositoryPermission.WRITE.roleName(repositoryId)" { + val userDisplayName = UserDisplayName("abc", "Test") + + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + userDisplayName, + createBody.idMatchers, + createBody.reason.mapToModel(), + createBody.comment + ).id + + definitionService.archive(definitionId, userDisplayName) + + requestShouldRequireRole( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + role = RepositoryPermission.WRITE.roleName(repositoryId) + ) { + post("/resolutions/vulnerabilities/$definitionId/restore") + } + } + + "respond with 'Forbidden' when repository ID cannot be resolved" { + requestShouldRequireAuthentication( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + successStatus = HttpStatusCode.Forbidden + ) { + post("/resolutions/vulnerabilities/9999/restore") + } + } + } + + "PatchVulnerabilityResolution" should { + "require role RepositoryPermission.WRITE.roleName(repositoryId)" { + val definitionId = definitionService.create( + RepositoryId(repositoryId), + runId, + UserDisplayName("abc", "Test"), + createBody.idMatchers, + createBody.reason.mapToModel(), + createBody.comment + ).id + + requestShouldRequireRole( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + role = RepositoryPermission.WRITE.roleName(repositoryId) + ) { + patch("/resolutions/vulnerabilities/$definitionId") { + setBody( + PatchVulnerabilityResolution( + comment = "Updated comment.".asPresent() + ) + ) + } + } + } + + "respond with 'Forbidden' when repository ID cannot be resolved" { + requestShouldRequireAuthentication( + routes = { resolutionsRoutes(ortRunService, definitionService) }, + successStatus = HttpStatusCode.Forbidden + ) { + patch("/resolutions/vulnerabilities/9999") { + setBody( + PatchVulnerabilityResolution( + comment = "Updated comment.".asPresent() + ) + ) + } + } + } + } +}) diff --git a/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolutionIntegrationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolutionIntegrationTest.kt new file mode 100644 index 0000000000..a862bbbcf1 --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/DeleteVulnerabilityResolutionIntegrationTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.ResolutionsIntegrationTest +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason + +class DeleteVulnerabilityResolutionIntegrationTest : ResolutionsIntegrationTest({ + var runId = 0L + + lateinit var fixtures: Fixtures + + beforeEach { + fixtures = dbExtension.fixtures + runId = fixtures.ortRun.id + } + + "DeleteVulnerabilityResolution" should { + "archive the definition" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + val deleteResponse = client.delete("/resolutions/vulnerabilities/$definitionId") + + with(deleteResponse.body()) { + idMatchers shouldBe listOf("CVE-2020-15250") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Comment." + archived shouldBe true + changes shouldHaveSize 2 + changes.first().user shouldBe UserDisplayName("test", "Test User") + changes.first().action shouldBe ChangeEventAction.CREATE + changes.last().user shouldBe UserDisplayName("test", "Test User") + changes.last().action shouldBe ChangeEventAction.ARCHIVE + } + } + } + + "respond with 'No content' if the definition was already archived" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + client.delete("/resolutions/vulnerabilities/$definitionId") + + val deleteResponse = client.delete("/resolutions/vulnerabilities/$definitionId") + + deleteResponse shouldHaveStatus HttpStatusCode.NoContent + } + } + } +}) diff --git a/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PatchVulnerabilityResolutionIntegrationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PatchVulnerabilityResolutionIntegrationTest.kt new file mode 100644 index 0000000000..c8aef1aede --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PatchVulnerabilityResolutionIntegrationTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.resolutions.PatchVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.ResolutionsIntegrationTest +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent + +class PatchVulnerabilityResolutionIntegrationTest : ResolutionsIntegrationTest({ + var runId = 0L + + lateinit var fixtures: Fixtures + + beforeEach { + fixtures = dbExtension.fixtures + runId = fixtures.ortRun.id + } + + "PatchVulnerabilityResolution" should { + "update a vulnerability resolution definition" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + val patchResponse = client.patch("/resolutions/vulnerabilities/$definitionId") { + setBody( + PatchVulnerabilityResolution( + listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp").asPresent(), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY.asPresent(), + "Updated comment.".asPresent() + ) + ) + } + + with(patchResponse.body()) { + idMatchers shouldBe listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Updated comment." + archived shouldBe false + changes shouldHaveSize 2 + changes.first().user shouldBe UserDisplayName("test", "Test User") + changes.first().action shouldBe ChangeEventAction.CREATE + changes.last().user shouldBe UserDisplayName("test", "Test User") + changes.last().action shouldBe ChangeEventAction.UPDATE + } + } + } + + "respond with 'Conflict' if the definition is archived" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.WILL_NOT_FIX_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + client.delete("/resolutions/vulnerabilities/$definitionId") + + val patchResponse = client.patch("/resolutions/vulnerabilities/$definitionId") { + setBody( + PatchVulnerabilityResolution( + comment = "Updated comment.".asPresent() + ) + ) + } + + patchResponse shouldHaveStatus HttpStatusCode.Conflict + } + } + } +}) diff --git a/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PostVulnerabilityResolutionIntegrationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PostVulnerabilityResolutionIntegrationTest.kt new file mode 100644 index 0000000000..2fe5a561d3 --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/PostVulnerabilityResolutionIntegrationTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.ResolutionsIntegrationTest +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason + +class PostVulnerabilityResolutionIntegrationTest : ResolutionsIntegrationTest({ + var runId = 0L + + lateinit var fixtures: Fixtures + + beforeEach { + fixtures = dbExtension.fixtures + runId = fixtures.ortRun.id + } + + "PostVulnerabilityResolution" should { + "add a new vulnerability resolution definition" { + resolutionsTestApplication { client -> + val response = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + ) + } + + response shouldHaveStatus HttpStatusCode.Created + + with(response.body()) { + idMatchers shouldBe listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Comment." + archived shouldBe false + changes shouldHaveSize 1 + changes.first().user shouldBe UserDisplayName("test", "Test User") + changes.first().action shouldBe ChangeEventAction.CREATE + } + } + } + } +}) diff --git a/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolutionIntegrationTest.kt b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolutionIntegrationTest.kt new file mode 100644 index 0000000000..b54bc9ee22 --- /dev/null +++ b/components/resolutions/backend/src/test/kotlin/routes/vulnerabilities/RestoreVulnerabilityResolutionIntegrationTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.components.resolutions.routes.vulnerabilities + +import io.kotest.assertions.ktor.client.shouldHaveStatus +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpStatusCode + +import org.eclipse.apoapsis.ortserver.components.resolutions.PostVulnerabilityResolution +import org.eclipse.apoapsis.ortserver.components.resolutions.ResolutionsIntegrationTest +import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason + +class RestoreVulnerabilityResolutionIntegrationTest : ResolutionsIntegrationTest({ + var runId = 0L + + lateinit var fixtures: Fixtures + + beforeEach { + fixtures = dbExtension.fixtures + runId = fixtures.ortRun.id + } + + "RestoreVulnerabilityResolution" should { + "restore the definition" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + client.delete("/resolutions/vulnerabilities/$definitionId") + + val restoreResponse = client.post("/resolutions/vulnerabilities/$definitionId/restore") + + restoreResponse shouldHaveStatus HttpStatusCode.OK + + with(restoreResponse.body()) { + idMatchers shouldBe listOf("CVE-2020-15250") + reason shouldBe VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + comment shouldBe "Comment." + archived shouldBe false + changes shouldHaveSize 3 + changes[0].user shouldBe UserDisplayName("test", "Test User") + changes[0].action shouldBe ChangeEventAction.CREATE + changes[1].user shouldBe UserDisplayName("test", "Test User") + changes[1].action shouldBe ChangeEventAction.ARCHIVE + changes[2].user shouldBe UserDisplayName("test", "Test User") + changes[2].action shouldBe ChangeEventAction.RESTORE + } + } + } + + "respond with 'No content' if the definition is not archived" { + resolutionsTestApplication { client -> + val createResponse = client.post("/resolutions/vulnerabilities") { + setBody( + PostVulnerabilityResolution( + runId, + listOf("CVE-2020-15250"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "Comment." + ) + ) + } + + val definitionId = (createResponse.body()).id + + val restoreResponse = client.post("/resolutions/vulnerabilities/$definitionId/restore") + + restoreResponse shouldHaveStatus HttpStatusCode.NoContent + } + } + } +}) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index e46b896c7c..d128aa6b97 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -84,6 +84,12 @@ dependencies { requireCapability("$group:routes:$version") } } + implementation(projects.components.resolutions.backend) + implementation(projects.components.resolutions.backend) { + capabilities { + requireCapability("$group:routes:$version") + } + } implementation(projects.components.secrets.backend) implementation(projects.components.secrets.backend) { capabilities { diff --git a/core/src/main/kotlin/apiDocs/RepositoriesDocs.kt b/core/src/main/kotlin/apiDocs/RepositoriesDocs.kt index 869774045a..c94cb745db 100644 --- a/core/src/main/kotlin/apiDocs/RepositoriesDocs.kt +++ b/core/src/main/kotlin/apiDocs/RepositoriesDocs.kt @@ -55,7 +55,6 @@ import org.eclipse.apoapsis.ortserver.api.v1.model.ScannerJob import org.eclipse.apoapsis.ortserver.api.v1.model.ScannerJobConfiguration import org.eclipse.apoapsis.ortserver.api.v1.model.SubmoduleFetchStrategy.FULLY_RECURSIVE import org.eclipse.apoapsis.ortserver.api.v1.model.User -import org.eclipse.apoapsis.ortserver.api.v1.model.UserDisplayName import org.eclipse.apoapsis.ortserver.api.v1.model.UserGroup import org.eclipse.apoapsis.ortserver.api.v1.model.UserWithGroups import org.eclipse.apoapsis.ortserver.api.v1.model.Username @@ -65,6 +64,7 @@ import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.PagingData import org.eclipse.apoapsis.ortserver.shared.apimodel.SortDirection import org.eclipse.apoapsis.ortserver.shared.apimodel.SortProperty +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName import org.eclipse.apoapsis.ortserver.shared.apimodel.asPresent import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody import org.eclipse.apoapsis.ortserver.shared.ktorutils.standardListQueryParameters diff --git a/core/src/main/kotlin/apiDocs/RunsDocs.kt b/core/src/main/kotlin/apiDocs/RunsDocs.kt index d56a467099..bd913e17a3 100644 --- a/core/src/main/kotlin/apiDocs/RunsDocs.kt +++ b/core/src/main/kotlin/apiDocs/RunsDocs.kt @@ -52,18 +52,22 @@ import org.eclipse.apoapsis.ortserver.api.v1.model.RuleViolation import org.eclipse.apoapsis.ortserver.api.v1.model.RuleViolationResolution import org.eclipse.apoapsis.ortserver.api.v1.model.Severity import org.eclipse.apoapsis.ortserver.api.v1.model.ShortestDependencyPath -import org.eclipse.apoapsis.ortserver.api.v1.model.UserDisplayName import org.eclipse.apoapsis.ortserver.api.v1.model.VcsInfo import org.eclipse.apoapsis.ortserver.api.v1.model.Vulnerability import org.eclipse.apoapsis.ortserver.api.v1.model.VulnerabilityRating import org.eclipse.apoapsis.ortserver.api.v1.model.VulnerabilityReference import org.eclipse.apoapsis.ortserver.api.v1.model.VulnerabilityResolution import org.eclipse.apoapsis.ortserver.api.v1.model.VulnerabilityWithDetails +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedSearchResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.PagingData import org.eclipse.apoapsis.ortserver.shared.apimodel.SortDirection import org.eclipse.apoapsis.ortserver.shared.apimodel.SortProperty +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.shared.ktorutils.jsonBody import org.eclipse.apoapsis.ortserver.shared.ktorutils.standardListQueryParameters @@ -300,7 +304,21 @@ val getRunVulnerabilities: RouteConfig.() -> Unit = { VulnerabilityResolution( externalId = "CVE-2021-1234", reason = "INEFFECTIVE_VULNERABILITY", - comment = "A comment why the vulnerability can be resolved." + comment = "A comment why the vulnerability can be resolved.", + definition = VulnerabilityResolutionDefinition( + id = 1, + idMatchers = listOf("CVE-2021-1234"), + reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + comment = "A comment why the vulnerability can be resolved.", + archived = false, + changes = listOf( + ChangeEvent( + user = UserDisplayName(username = "test"), + occurredAt = CREATED_AT, + action = ChangeEventAction.CREATE + ) + ) + ) ) ), advisor = AdvisorDetails( diff --git a/core/src/main/kotlin/di/Module.kt b/core/src/main/kotlin/di/Module.kt index 6cf8df9d98..69065e7c98 100644 --- a/core/src/main/kotlin/di/Module.kt +++ b/core/src/main/kotlin/di/Module.kt @@ -36,6 +36,7 @@ import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginEventStore import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginService import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginTemplateEventStore import org.eclipse.apoapsis.ortserver.components.pluginmanager.PluginTemplateService +import org.eclipse.apoapsis.ortserver.components.resolutions.VulnerabilityResolutionDefinitionService import org.eclipse.apoapsis.ortserver.components.secrets.SecretService import org.eclipse.apoapsis.ortserver.config.ConfigManager import org.eclipse.apoapsis.ortserver.core.plugins.customSerializersModule @@ -189,6 +190,7 @@ fun ortServerModule(config: ApplicationConfig, db: Database?, authorizationServi singleOf(::RepositoryService) singleOf(::RuleViolationService) singleOf(::SecretService) + singleOf(::VulnerabilityResolutionDefinitionService) singleOf(::VulnerabilityService) if (authorizationService != null) { diff --git a/core/src/main/kotlin/plugins/Routing.kt b/core/src/main/kotlin/plugins/Routing.kt index c4c14b8220..3158dad1cd 100644 --- a/core/src/main/kotlin/plugins/Routing.kt +++ b/core/src/main/kotlin/plugins/Routing.kt @@ -28,6 +28,7 @@ import org.eclipse.apoapsis.ortserver.components.adminconfig.adminConfigRoutes import org.eclipse.apoapsis.ortserver.components.authorization.keycloak.SecurityConfigurations import org.eclipse.apoapsis.ortserver.components.infrastructureservices.infrastructureServicesRoutes import org.eclipse.apoapsis.ortserver.components.pluginmanager.pluginManagerRoutes +import org.eclipse.apoapsis.ortserver.components.resolutions.resolutionsRoutes import org.eclipse.apoapsis.ortserver.components.secrets.secretsRoutes import org.eclipse.apoapsis.ortserver.compositions.secretsroutes.secretsCompositionRoutes import org.eclipse.apoapsis.ortserver.core.api.admin @@ -56,6 +57,7 @@ fun Application.configureRouting() { pluginManagerRoutes(get(), get(), get()) products() repositories() + resolutionsRoutes(get(), get()) runs() secretsCompositionRoutes(get(), get()) secretsRoutes(get(), get()) diff --git a/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt b/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt index 125ff30beb..dd52391d8a 100644 --- a/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt +++ b/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt @@ -26,6 +26,7 @@ import io.kotest.assertions.ktor.client.haveHeader import io.kotest.assertions.ktor.client.shouldHaveStatus import io.kotest.engine.spec.tempdir import io.kotest.inspectors.forAll +import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldBeSingleton import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldHaveSize @@ -101,9 +102,11 @@ import org.eclipse.apoapsis.ortserver.model.LogSource import org.eclipse.apoapsis.ortserver.model.OrtRun import org.eclipse.apoapsis.ortserver.model.OrtRunStatus import org.eclipse.apoapsis.ortserver.model.PluginConfig +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.RepositoryType import org.eclipse.apoapsis.ortserver.model.Severity import org.eclipse.apoapsis.ortserver.model.UserDisplayName +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.model.repositories.OrtRunRepository import org.eclipse.apoapsis.ortserver.model.runs.AnalyzerConfiguration import org.eclipse.apoapsis.ortserver.model.runs.Environment @@ -131,6 +134,8 @@ import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.PagedSearchResponse import org.eclipse.apoapsis.ortserver.shared.apimodel.SortDirection import org.eclipse.apoapsis.ortserver.shared.apimodel.SortProperty +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason as ApiVulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.shared.ktorutils.shouldHaveBody import org.eclipse.apoapsis.ortserver.storage.Key import org.eclipse.apoapsis.ortserver.storage.Storage @@ -811,6 +816,109 @@ class RunsRouteIntegrationTest : AbstractIntegrationTest({ } } + "return the definition of the vulnerability resolution if it originated from the server" { + integrationTestApplication { + val run1 = dbExtension.fixtures.createOrtRun(repositoryId) + val definitionId = dbExtension.fixtures.createVulnerabilityResolutionDefinition( + RepositoryId(repositoryId), + run1.id, + listOf("CVE-2021-1234"), + VulnerabilityResolutionReason.INVALID_MATCH_VULNERABILITY + ) + + val run2 = dbExtension.fixtures.createOrtRun(repositoryId) + val advisorJobId = dbExtension.fixtures.createAdvisorJob(run2.id).id + dbExtension.fixtures.createAdvisorRun(advisorJobId, generateAdvisorResult()) + + val vulnerabilityResolution = VulnerabilityResolution( + "CVE-2018-14721", + "INEFFECTIVE_VULNERABILITY", + "Comment." + ) + + val repositoryConfiguration = + dbExtension.fixtures.createRepositoryConfiguration(run2.id, listOf(vulnerabilityResolution)) + + dbExtension.fixtures.resolvedConfigurationRepository.addResolutions( + run2.id, + repositoryConfiguration.resolutions + ) + + val response = superuserClient.get("/api/v1/runs/${run2.id}/vulnerabilities") + + response shouldHaveStatus HttpStatusCode.OK + val vulnerabilities = response.body>() + + vulnerabilities.data shouldHaveSize 2 + + with(vulnerabilities.data.first()) { + vulnerability.externalId shouldBe "CVE-2018-14721" + + resolutions.shouldBeSingleton { + it.definition should beNull() + } + } + + with(vulnerabilities.data.last()) { + vulnerability.externalId shouldBe "CVE-2021-1234" + + resolutions.shouldBeSingleton { + it.definition shouldBe VulnerabilityResolutionDefinition( + id = definitionId, + idMatchers = listOf("CVE-2021-1234"), + reason = ApiVulnerabilityResolutionReason.INVALID_MATCH_VULNERABILITY, + comment = "Comment.", + archived = false, + changes = emptyList() + ) + } + } + } + } + + "return new matching definitions that have been created after the run" { + integrationTestApplication { + val run = dbExtension.fixtures.createOrtRun(repositoryId) + val advisorJobId = dbExtension.fixtures.createAdvisorJob(run.id).id + dbExtension.fixtures.createAdvisorRun(advisorJobId, generateAdvisorResult()) + + val definitionId = dbExtension.fixtures.createVulnerabilityResolutionDefinition( + RepositoryId(repositoryId), + run.id, + listOf("CVE-2021-1234"), + VulnerabilityResolutionReason.INVALID_MATCH_VULNERABILITY + ) + + val response = superuserClient.get("/api/v1/runs/${run.id}/vulnerabilities") + + response shouldHaveStatus HttpStatusCode.OK + val vulnerabilities = response.body>() + + vulnerabilities.data shouldHaveSize 2 + + with(vulnerabilities.data.first()) { + vulnerability.externalId shouldBe "CVE-2018-14721" + resolutions.shouldBeEmpty() + newMatchingResolutionDefinitions.shouldBeEmpty() + } + + with(vulnerabilities.data.last()) { + vulnerability.externalId shouldBe "CVE-2021-1234" + resolutions.shouldBeEmpty() + newMatchingResolutionDefinitions shouldHaveSize 1 + + newMatchingResolutionDefinitions.first() shouldBe VulnerabilityResolutionDefinition( + id = definitionId, + idMatchers = listOf("CVE-2021-1234"), + reason = ApiVulnerabilityResolutionReason.INVALID_MATCH_VULNERABILITY, + comment = "Comment.", + archived = false, + changes = emptyList() + ) + } + } + } + "require RepositoryPermission.READ_ORT_RUNS" { val run = ortRunRepository.create( repositoryId, diff --git a/dao/src/main/kotlin/repositories/ortrun/OrtRunsTable.kt b/dao/src/main/kotlin/repositories/ortrun/OrtRunsTable.kt index 6014547360..67706aef66 100644 --- a/dao/src/main/kotlin/repositories/ortrun/OrtRunsTable.kt +++ b/dao/src/main/kotlin/repositories/ortrun/OrtRunsTable.kt @@ -83,6 +83,8 @@ object OrtRunsTable : SortableTable("ort_runs") { val traceId = text("trace_id").nullable() val environmentConfigPath = text("environment_config_path").nullable() val userDisplayName = reference("user_id", UserDisplayNamesTable.id).nullable() + val outdated = bool("outdated").default(false) + val outdatedMessage = text("outdated_message").nullable() /** Get the id of the analyzer run for the given ORT run [id]. Returns `null` if no run is found. */ fun getAnalyzerRunIdById(id: Long): Long? = @@ -117,6 +119,8 @@ class OrtRunDao(id: EntityID) : LongEntity(id) { var vcsProcessedId by OrtRunsTable.vcsProcessedId var environmentConfigPath by OrtRunsTable.environmentConfigPath var userDisplayName by UserDisplayNameDao optionalReferencedOn OrtRunsTable.userDisplayName + var outdated by OrtRunsTable.outdated + var outdatedMessage by OrtRunsTable.outdatedMessage val advisorJob by AdvisorJobDao optionalBackReferencedOn AdvisorJobsTable.ortRunId val analyzerJob by AnalyzerJobDao optionalBackReferencedOn AnalyzerJobsTable.ortRunId @@ -152,6 +156,8 @@ class OrtRunDao(id: EntityID) : LongEntity(id) { traceId = traceId, environmentConfigPath = environmentConfigPath, userDisplayName = userDisplayName?.mapToModel(), + outdated = outdated, + outdatedMessage = outdatedMessage ) /** diff --git a/dao/src/main/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepository.kt b/dao/src/main/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepository.kt index 06aa52e0fb..caba47c431 100644 --- a/dao/src/main/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepository.kt +++ b/dao/src/main/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepository.kt @@ -23,6 +23,7 @@ import org.eclipse.apoapsis.ortserver.dao.blockingQuery import org.eclipse.apoapsis.ortserver.dao.entityQuery import org.eclipse.apoapsis.ortserver.dao.mapAndDeduplicate import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunDao +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable import org.eclipse.apoapsis.ortserver.dao.tables.shared.IdentifierDao import org.eclipse.apoapsis.ortserver.model.repositories.RepositoryConfigurationRepository import org.eclipse.apoapsis.ortserver.model.runs.repository.Curations @@ -36,8 +37,12 @@ import org.eclipse.apoapsis.ortserver.model.runs.repository.ProvenanceSnippetCho import org.eclipse.apoapsis.ortserver.model.runs.repository.RepositoryAnalyzerConfiguration import org.eclipse.apoapsis.ortserver.model.runs.repository.RepositoryConfiguration import org.eclipse.apoapsis.ortserver.model.runs.repository.Resolutions +import org.eclipse.apoapsis.ortserver.model.runs.repository.VulnerabilityResolution import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SizedCollection +import org.jetbrains.exposed.sql.SizedIterable +import org.jetbrains.exposed.sql.and /** * An implementation of [RepositoryConfigurationRepository] that stores repository configurations in @@ -55,7 +60,38 @@ class DaoRepositoryConfigurationRepository(private val db: Database) : Repositor licenseChoices: LicenseChoices, provenanceSnippetChoices: List ): RepositoryConfiguration = db.blockingQuery { - RepositoryConfigurationDao.new { + val ortRun = OrtRunDao[ortRunId].mapToModel() + val vulnerabilityResolutions = mapAndDeduplicate( + resolutions.vulnerabilities, + VulnerabilityResolutionDao::getOrPut + ) + + val idToVulnerabilityResolutionDaos = VulnerabilityResolutionDefinitionsTable + .select(VulnerabilityResolutionDefinitionsTable.columns) + .where { + (VulnerabilityResolutionDefinitionsTable.repositoryId eq ortRun.repositoryId) and + (VulnerabilityResolutionDefinitionsTable.archived eq false) + } + .associateBy({ row -> row[VulnerabilityResolutionDefinitionsTable.id].value }, { row -> + row[VulnerabilityResolutionDefinitionsTable.idMatchers].map { idMatcher -> + VulnerabilityResolutionDao.getOrPut( + VulnerabilityResolution( + idMatcher, + row[VulnerabilityResolutionDefinitionsTable.reason], + row[VulnerabilityResolutionDefinitionsTable.comment] + ) + ) + } + }) + + val combinedVulnerabilityResolutions: SizedIterable = SizedCollection( + buildList { + addAll(vulnerabilityResolutions.toList()) + addAll(idToVulnerabilityResolutionDaos.values.flatten()) + }.distinctBy { it.id.value } + ) + + val repositoryConfiguration = RepositoryConfigurationDao.new { this.ortRun = OrtRunDao[ortRunId] this.repositoryAnalyzerConfiguration = analyzerConfig?.let { RepositoryAnalyzerConfigurationDao.getOrPut(it) @@ -66,8 +102,7 @@ class DaoRepositoryConfigurationRepository(private val db: Database) : Repositor this.issueResolutions = mapAndDeduplicate(resolutions.issues, IssueResolutionDao::getOrPut) this.ruleViolationResolutions = mapAndDeduplicate(resolutions.ruleViolations, RuleViolationResolutionDao::getOrPut) - this.vulnerabilityResolutions = - mapAndDeduplicate(resolutions.vulnerabilities, VulnerabilityResolutionDao::getOrPut) + this.vulnerabilityResolutions = combinedVulnerabilityResolutions this.curations = mapAndDeduplicate(curations.packages, ::createPackageCuration) this.licenseFindingCurations = mapAndDeduplicate(curations.licenseFindings, LicenseFindingCurationDao::getOrPut) @@ -78,6 +113,19 @@ class DaoRepositoryConfigurationRepository(private val db: Database) : Repositor mapAndDeduplicate(licenseChoices.packageLicenseChoices, ::createPackageLicenseChoice) this.provenanceSnippetChoices = mapAndDeduplicate(provenanceSnippetChoices, SnippetChoicesDao::getOrPut) }.mapToModel() + + idToVulnerabilityResolutionDaos.forEach { + (vulnerabilityResolutionDefinitionId, vulnerabilityResolutionDaoList) -> + vulnerabilityResolutionDaoList.forEach { + RepositoryConfigurationsVulnerabilityResolutionsTable.addDefinitionId( + repositoryConfiguration.id, + it.id.value, + vulnerabilityResolutionDefinitionId + ) + } + } + + repositoryConfiguration } override fun get(id: Long): RepositoryConfiguration? = db.entityQuery { diff --git a/dao/src/main/kotlin/repositories/repositoryconfiguration/RepositoryConfigurationsVulnerabilityResolutionsTable.kt b/dao/src/main/kotlin/repositories/repositoryconfiguration/RepositoryConfigurationsVulnerabilityResolutionsTable.kt index 6a6ab67387..6ca26ac6b4 100644 --- a/dao/src/main/kotlin/repositories/repositoryconfiguration/RepositoryConfigurationsVulnerabilityResolutionsTable.kt +++ b/dao/src/main/kotlin/repositories/repositoryconfiguration/RepositoryConfigurationsVulnerabilityResolutionsTable.kt @@ -19,7 +19,11 @@ package org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable + import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.update /** * An intermediate table to store references from [RepositoryConfigurationsTable] and [VulnerabilityResolutionsTable]. @@ -28,7 +32,19 @@ object RepositoryConfigurationsVulnerabilityResolutionsTable : Table("repository_configurations_vulnerability_resolutions") { val repositoryConfigurationId = reference("repository_configuration_id", RepositoryConfigurationsTable) val vulnerabilityResolutionId = reference("vulnerability_resolution_id", VulnerabilityResolutionsTable) + val vulnerabilityResolutionDefinitionId = reference( + "vulnerability_resolution_definition_id", + VulnerabilityResolutionDefinitionsTable + ).nullable() override val primaryKey: PrimaryKey get() = PrimaryKey(repositoryConfigurationId, vulnerabilityResolutionId, name = "${tableName}_pkey") + + fun addDefinitionId(repositoryConfigId: Long, vulnerabilityResId: Long, vulnerabilityResolutionDefId: Long) = + update({ + (repositoryConfigurationId eq repositoryConfigId) and + (vulnerabilityResolutionId eq vulnerabilityResId) + }) { stmt -> + stmt[vulnerabilityResolutionDefinitionId] = vulnerabilityResolutionDefId + } } diff --git a/dao/src/main/kotlin/tables/ChangeLogTable.kt b/dao/src/main/kotlin/tables/ChangeLogTable.kt new file mode 100644 index 0000000000..fffeba8ab8 --- /dev/null +++ b/dao/src/main/kotlin/tables/ChangeLogTable.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.dao.tables + +import org.eclipse.apoapsis.ortserver.dao.repositories.userDisplayName.UserDisplayNameDao +import org.eclipse.apoapsis.ortserver.model.ChangeEvent +import org.eclipse.apoapsis.ortserver.model.ChangeEventAction +import org.eclipse.apoapsis.ortserver.model.ChangeEventEntityType +import org.eclipse.apoapsis.ortserver.model.UserDisplayName + +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.kotlin.datetime.timestamp + +/* + * A table to store change log entries representing user-performed change events. + */ +object ChangeLogTable : Table("change_log") { + val entityType = text("entity_type") + val entityId = text("entity_id") + val userId = text("user_id") + val occurredAt = timestamp("occurred_at") + val action = text("action") + + fun insert( + entityTypeInput: ChangeEventEntityType, + entityIdInput: String, + userIdInput: String, + actionInput: ChangeEventAction + ) { + insert { + it[entityType] = entityTypeInput.name + it[entityId] = entityIdInput + it[userId] = userIdInput + it[action] = actionInput.name + } + } + + fun getAllByEntityTypeAndId( + entityTypeSearch: ChangeEventEntityType, + entityIdSearch: String + ): List { + return select(columns) + .where { (entityType eq entityTypeSearch.name) and (entityId eq entityIdSearch) } + .orderBy(occurredAt, SortOrder.ASC) + .map { row -> + ChangeEvent( + user = UserDisplayNameDao.findById(row[userId])?.mapToModel() + ?: UserDisplayName(row[userId], "Unknown"), + occurredAt = row[occurredAt], + action = ChangeEventAction.valueOf(row[action]) + ) + } + } +} diff --git a/dao/src/main/kotlin/tables/VulnerabilityResolutionDefinitionsTable.kt b/dao/src/main/kotlin/tables/VulnerabilityResolutionDefinitionsTable.kt new file mode 100644 index 0000000000..674e6090b8 --- /dev/null +++ b/dao/src/main/kotlin/tables/VulnerabilityResolutionDefinitionsTable.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.dao.tables + +import org.eclipse.apoapsis.ortserver.dao.repositories.repository.RepositoriesTable +import org.eclipse.apoapsis.ortserver.dao.utils.jsonb +import org.eclipse.apoapsis.ortserver.model.ChangeEventEntityType +import org.eclipse.apoapsis.ortserver.model.RepositoryId +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.model.util.OptionalValue + +import org.jetbrains.exposed.dao.id.LongIdTable +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.update + +/** + * A table to store definitions of vulnerability resolutions. + */ +object VulnerabilityResolutionDefinitionsTable : LongIdTable("vulnerability_resolution_definitions") { + val repositoryId = reference("repository_id", RepositoriesTable) + + val contextRunId = long("context_run_id") + val idMatchers = jsonb>("id_matchers") + val reason = text("reason") + val comment = text("comment") + val archived = bool("archived").default(false) + + fun insert( + hierarchyId: RepositoryId, + runIdInput: Long, + idMatchersInput: List, + reasonInput: VulnerabilityResolutionReason, + commentInput: String + ): Long { + return insertAndGetId { + it[repositoryId] = hierarchyId.value + it[contextRunId] = runIdInput + it[idMatchers] = idMatchersInput + it[reason] = reasonInput.name + it[comment] = commentInput + }.value + } + + fun get(definitionId: Long): VulnerabilityResolutionDefinition { + return select(columns) + .where { id eq definitionId } + .single() + .toVulnerabilityResolutionDefinition() + } + + fun getOrNull(definitionId: Long): VulnerabilityResolutionDefinition? { + val row = select(columns) + .where { id eq definitionId } + .singleOrNull() ?: return null + + return row.toVulnerabilityResolutionDefinition() + } + + fun updateDefinition( + definitionId: Long, + idMatchersInput: OptionalValue> = OptionalValue.Absent, + reasonInput: OptionalValue = OptionalValue.Absent, + commentInput: OptionalValue = OptionalValue.Absent, + archivedInput: OptionalValue = OptionalValue.Absent + ) { + update({ id eq definitionId }) { stmt -> + idMatchersInput.ifPresent { stmt[idMatchers] = it } + reasonInput.ifPresent { stmt[reason] = it.name } + commentInput.ifPresent { stmt[comment] = it } + archivedInput.ifPresent { stmt[archived] = it } + } + } + + fun ResultRow.toVulnerabilityResolutionDefinition() = VulnerabilityResolutionDefinition( + this[id].value, + RepositoryId(this[repositoryId].value), + this[contextRunId], + this[idMatchers], + VulnerabilityResolutionReason.valueOf(this[reason]), + this[comment], + this[archived], + ChangeLogTable.getAllByEntityTypeAndId( + ChangeEventEntityType.VULNERABILITY_RESOLUTION_DEFINITION, + this[id].value.toString() + ) + ) +} diff --git a/dao/src/main/resources/db/migration/V120__addOutdatedToOrtRun.sql b/dao/src/main/resources/db/migration/V120__addOutdatedToOrtRun.sql new file mode 100644 index 0000000000..3166e2619d --- /dev/null +++ b/dao/src/main/resources/db/migration/V120__addOutdatedToOrtRun.sql @@ -0,0 +1,3 @@ +ALTER TABLE ort_runs + ADD COLUMN outdated boolean DEFAULT FALSE NOT NULL, + ADD COLUMN outdated_message text NULL; diff --git a/dao/src/main/resources/db/migration/V121__addChangeLogTable.sql b/dao/src/main/resources/db/migration/V121__addChangeLogTable.sql new file mode 100644 index 0000000000..47b2775ebc --- /dev/null +++ b/dao/src/main/resources/db/migration/V121__addChangeLogTable.sql @@ -0,0 +1,11 @@ +CREATE TABLE change_log ( + entity_type text NOT NULL, + entity_id text NOT NULL, + user_id varchar(40) NOT NULL, + occurred_at timestamp DEFAULT NOW() NOT NULL, + action text NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_change_log_entity_type ON change_log(entity_type); + +CREATE INDEX IF NOT EXISTS idx_change_log_entity_id ON change_log(entity_id); diff --git a/dao/src/main/resources/db/migration/V122__addVulnerabilityResolutionDefinitionsTable.sql b/dao/src/main/resources/db/migration/V122__addVulnerabilityResolutionDefinitionsTable.sql new file mode 100644 index 0000000000..da7b3a5cdf --- /dev/null +++ b/dao/src/main/resources/db/migration/V122__addVulnerabilityResolutionDefinitionsTable.sql @@ -0,0 +1,16 @@ +CREATE TABLE vulnerability_resolution_definitions +( + id bigserial PRIMARY KEY, + repository_id bigint REFERENCES repositories NOT NULL, + context_run_id bigint NOT NULL, + id_matchers jsonb NOT NULL, + reason text NOT NULL, + comment text NOT NULL, + archived boolean DEFAULT FALSE NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_vulnerability_resolution_definitions_repository_id +ON vulnerability_resolution_definitions(repository_id); + +CREATE INDEX IF NOT EXISTS idx_vulnerability_resolution_definitions_context_run_id +ON vulnerability_resolution_definitions(context_run_id); diff --git a/dao/src/main/resources/db/migration/V123__addVulnerabilityResolutionDefinitionIdToRepositoryConfigurationsVulnerabilityResolutionsTable.sql b/dao/src/main/resources/db/migration/V123__addVulnerabilityResolutionDefinitionIdToRepositoryConfigurationsVulnerabilityResolutionsTable.sql new file mode 100644 index 0000000000..60b44f21b5 --- /dev/null +++ b/dao/src/main/resources/db/migration/V123__addVulnerabilityResolutionDefinitionIdToRepositoryConfigurationsVulnerabilityResolutionsTable.sql @@ -0,0 +1,3 @@ +ALTER TABLE repository_configurations_vulnerability_resolutions + ADD COLUMN vulnerability_resolution_definition_id bigint + REFERENCES vulnerability_resolution_definitions NULL; diff --git a/dao/src/test/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepositoryTest.kt b/dao/src/test/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepositoryTest.kt index 91dcd93fa3..73bf14f6e4 100644 --- a/dao/src/test/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepositoryTest.kt +++ b/dao/src/test/kotlin/repositories/repositoryconfiguration/DaoRepositoryConfigurationRepositoryTest.kt @@ -21,6 +21,8 @@ package org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.collections.containExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.should @@ -28,7 +30,9 @@ import io.kotest.matchers.shouldBe import org.eclipse.apoapsis.ortserver.dao.test.DatabaseTestExtension import org.eclipse.apoapsis.ortserver.dao.test.Fixtures +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.RepositoryType +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.model.runs.Identifier import org.eclipse.apoapsis.ortserver.model.runs.PackageManagerConfiguration import org.eclipse.apoapsis.ortserver.model.runs.RemoteArtifact @@ -141,6 +145,62 @@ class DaoRepositoryConfigurationRepositoryTest : WordSpec({ includes.paths should containExactly(pathInclude) } } + + "inject vulnerability resolutions that have been defined for the repository" { + fixtures.createVulnerabilityResolutionDefinition( + idMatchers = listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + reason = VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY + ) + + val ortRun2Id = fixtures.createOrtRun().id + + val createdRepositoryConfiguration = repositoryConfigurationRepository.create( + ortRun2Id, repositoryConfig + ) + + val dbEntry = repositoryConfigurationRepository.get(createdRepositoryConfiguration.id) + + dbEntry shouldNotBeNull { + resolutions.vulnerabilities shouldHaveSize 3 + resolutions.vulnerabilities shouldContainExactlyInAnyOrder listOf( + vulnerabilityResolution, + VulnerabilityResolution( + "CVE-2020-15250", + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY.name, + "Comment." + ), + VulnerabilityResolution( + "GHSA-269g-pwp5-87pp", + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY.name, + "Comment." + ) + ) + } + } + + "not inject vulnerability resolutions made for other repositories" { + fixtures.createVulnerabilityResolutionDefinition( + RepositoryId(fixtures.ortRun.repositoryId), + ortRunId, + listOf("CVE-2020-15250", "GHSA-269g-pwp5-87pp"), + VulnerabilityResolutionReason.INEFFECTIVE_VULNERABILITY, + "comment" + ) + + val repo2Id = fixtures.createRepository(url = "https://example.com/repo2.git").id + val run2Id = fixtures.createOrtRun(repo2Id).id + + val createdRepositoryConfiguration = repositoryConfigurationRepository.create( + run2Id, repositoryConfig + ) + + val dbEntry = repositoryConfigurationRepository.get(createdRepositoryConfiguration.id) + + dbEntry shouldNotBeNull { + resolutions.vulnerabilities shouldHaveSize 1 + resolutions.vulnerabilities.first() shouldBe vulnerabilityResolution + } + } } "get" should { diff --git a/dao/src/testFixtures/kotlin/Fixtures.kt b/dao/src/testFixtures/kotlin/Fixtures.kt index 7b0e674b6b..55885a624d 100644 --- a/dao/src/testFixtures/kotlin/Fixtures.kt +++ b/dao/src/testFixtures/kotlin/Fixtures.kt @@ -22,6 +22,7 @@ package org.eclipse.apoapsis.ortserver.dao.test import kotlinx.datetime.Clock import org.eclipse.apoapsis.ortserver.dao.blockingQuery +import org.eclipse.apoapsis.ortserver.dao.dbQuery import org.eclipse.apoapsis.ortserver.dao.repositories.advisorjob.DaoAdvisorJobRepository import org.eclipse.apoapsis.ortserver.dao.repositories.advisorrun.DaoAdvisorRunRepository import org.eclipse.apoapsis.ortserver.dao.repositories.analyzerjob.DaoAnalyzerJobRepository @@ -41,6 +42,7 @@ import org.eclipse.apoapsis.ortserver.dao.repositories.resolvedconfiguration.Dao import org.eclipse.apoapsis.ortserver.dao.repositories.scannerjob.DaoScannerJobRepository import org.eclipse.apoapsis.ortserver.dao.repositories.scannerrun.DaoScannerRunRepository import org.eclipse.apoapsis.ortserver.dao.repositories.secret.DaoSecretRepository +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable import org.eclipse.apoapsis.ortserver.dao.tables.shared.IdentifierDao import org.eclipse.apoapsis.ortserver.model.AdvisorJobConfiguration import org.eclipse.apoapsis.ortserver.model.AnalyzerJobConfiguration @@ -50,9 +52,11 @@ import org.eclipse.apoapsis.ortserver.model.Jobs import org.eclipse.apoapsis.ortserver.model.NotifierJobConfiguration import org.eclipse.apoapsis.ortserver.model.PluginConfig import org.eclipse.apoapsis.ortserver.model.ReporterJobConfiguration +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.RepositoryType import org.eclipse.apoapsis.ortserver.model.ScannerJobConfiguration import org.eclipse.apoapsis.ortserver.model.Severity +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.model.runs.AnalyzerConfiguration import org.eclipse.apoapsis.ortserver.model.runs.DependencyGraph import org.eclipse.apoapsis.ortserver.model.runs.Environment @@ -67,6 +71,12 @@ import org.eclipse.apoapsis.ortserver.model.runs.ShortestDependencyPath import org.eclipse.apoapsis.ortserver.model.runs.VcsInfo import org.eclipse.apoapsis.ortserver.model.runs.advisor.AdvisorConfiguration import org.eclipse.apoapsis.ortserver.model.runs.advisor.AdvisorResult +import org.eclipse.apoapsis.ortserver.model.runs.repository.Curations +import org.eclipse.apoapsis.ortserver.model.runs.repository.Excludes +import org.eclipse.apoapsis.ortserver.model.runs.repository.Includes +import org.eclipse.apoapsis.ortserver.model.runs.repository.LicenseChoices +import org.eclipse.apoapsis.ortserver.model.runs.repository.Resolutions +import org.eclipse.apoapsis.ortserver.model.runs.repository.VulnerabilityResolution import org.jetbrains.exposed.sql.Database @@ -74,6 +84,7 @@ import org.jetbrains.exposed.sql.Database * A helper class to manage test fixtures. It provides default instances as well as helper functions to create custom * instances. */ +@Suppress("TooManyFunctions") class Fixtures(private val db: Database) { val advisorJobRepository = DaoAdvisorJobRepository(db) val advisorRunRepository = DaoAdvisorRunRepository(db) @@ -294,6 +305,25 @@ class Fixtures(private val db: Database) { results = results ) + fun createRepositoryConfiguration( + runId: Long = ortRun.id, + vulnerabilityResolutions: List = emptyList() + ) = repositoryConfigurationRepository.create( + ortRunId = runId, + analyzerConfig = null, + excludes = Excludes(emptyList(), emptyList()), + includes = Includes(emptyList()), + resolutions = Resolutions( + issues = emptyList(), + ruleViolations = emptyList(), + vulnerabilities = vulnerabilityResolutions + ), + curations = Curations(emptyList(), emptyList()), + packageConfigurations = emptyList(), + licenseChoices = LicenseChoices(emptyList(), emptyList()), + provenanceSnippetChoices = emptyList() + ) + fun generatePackage( identifier: Identifier, authors: Set = emptySet(), @@ -343,4 +373,20 @@ class Fixtures(private val db: Database) { isMetadataOnly = false, isModified = false ) + + suspend fun createVulnerabilityResolutionDefinition( + hierarchyId: RepositoryId = RepositoryId(repository.id), + contextRunId: Long = ortRun.id, + idMatchers: List, + reason: VulnerabilityResolutionReason, + comment: String = "Comment." + ) = db.dbQuery { + VulnerabilityResolutionDefinitionsTable.insert( + hierarchyId, + contextRunId, + idMatchers, + reason, + comment + ) + } } diff --git a/model/src/commonMain/kotlin/AppliedVulnerabilityResolution.kt b/model/src/commonMain/kotlin/AppliedVulnerabilityResolution.kt new file mode 100644 index 0000000000..0bb9edc43f --- /dev/null +++ b/model/src/commonMain/kotlin/AppliedVulnerabilityResolution.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.model + +import org.eclipse.apoapsis.ortserver.model.runs.repository.VulnerabilityResolution + +/** + * A data class that represents a [VulnerabilityResolution] that has been applied, along with its definition if + * available. + */ +data class AppliedVulnerabilityResolution( + /** The applied [VulnerabilityResolution]. */ + val resolution: VulnerabilityResolution, + + /** The definition of the [VulnerabilityResolution], if available. */ + val definition: VulnerabilityResolutionDefinition? = null +) diff --git a/model/src/commonMain/kotlin/ChangeEvent.kt b/model/src/commonMain/kotlin/ChangeEvent.kt new file mode 100644 index 0000000000..ca1d1d62dc --- /dev/null +++ b/model/src/commonMain/kotlin/ChangeEvent.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.model + +import kotlinx.datetime.Instant + +/** + * A data class representing a change event performed by a user. + */ +data class ChangeEvent( + /** The user who performed the change. */ + val user: UserDisplayName, + + /** The time the change occurred. */ + val occurredAt: Instant, + + /** The action performed. */ + val action: ChangeEventAction +) + +/** + * An enumeration of the entity types that can be affected by a [ChangeEvent]. + */ +enum class ChangeEventEntityType { + VULNERABILITY_RESOLUTION_DEFINITION +} + +/** + * An enumeration of the actions that can be performed, resulting in a [ChangeEvent]. + */ +enum class ChangeEventAction { + /** The creation of a new entity. */ + CREATE, + + /** The update of an existing entity. */ + UPDATE, + + /** The archival, i.e. soft deletion, of an existing entity. */ + ARCHIVE, + + /** The restoration, i.e. un-archival, of an archived entity. */ + RESTORE +} diff --git a/model/src/commonMain/kotlin/OrtRun.kt b/model/src/commonMain/kotlin/OrtRun.kt index 8e4382fb00..f2085b6068 100644 --- a/model/src/commonMain/kotlin/OrtRun.kt +++ b/model/src/commonMain/kotlin/OrtRun.kt @@ -155,7 +155,17 @@ data class OrtRun( /** * Name of the user that triggered this run. */ - val userDisplayName: UserDisplayName? = null + val userDisplayName: UserDisplayName? = null, + + /** + * A flag to indicate if the results of the run are outdated, e.g. because of a new resolution. + */ + val outdated: Boolean = false, + + /** + * A message describing why the results of the run are outdated. + */ + val outdatedMessage: String? = null ) enum class OrtRunStatus( diff --git a/model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt b/model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt new file mode 100644 index 0000000000..779706160e --- /dev/null +++ b/model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.model + +/* + * A data class representing a vulnerability resolution definition. + */ +data class VulnerabilityResolutionDefinition( + /** The unique identifier of the vulnerability resolution definition. */ + val id: Long, + + /** + * The ID of the hierarchy to which this vulnerability resolution definition is scoped to. In the initial + * implementation this is always the repository level. + */ + val hierarchyId: RepositoryId, + + /** The ID of the run in which context the vulnerability resolution definition was made in. */ + val contextRunId: Long, + + /** The list of vulnerability ID matchers (regular expressions) to match the ids of the vulnerability to resolve. */ + val idMatchers: List, + + /** The reason why the vulnerability is resolved. */ + val reason: VulnerabilityResolutionReason, + + /** A comment to further explain why the [reason] is applicable here. */ + val comment: String, + + /** Whether the vulnerability resolution definition is archived. */ + val archived: Boolean, + + /** The list of change events associated with this vulnerability resolution definition. */ + val changes: List +) diff --git a/model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt b/model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt new file mode 100644 index 0000000000..f5cf0ab885 --- /dev/null +++ b/model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.model + +/** + * Possible reasons for resolving an [Vulnerability] using a [VulnerabilityResolution]. + */ +enum class VulnerabilityResolutionReason { + /** + * No remediation is available for this vulnerability, e.g., because it requires a change to be made + * by a third party that is not responsive. + */ + CANT_FIX_VULNERABILITY, + + /** + * The code in which the vulnerability was found is neither invoked in the project's code nor indirectly + * via another open source component. + */ + INEFFECTIVE_VULNERABILITY, + + /** + * The vulnerability is irrelevant due to a tooling or database mismatch, e.g., the package version used + * does not match the version for which the vulnerability provider has reported a vulnerability. + */ + INVALID_MATCH_VULNERABILITY, + + /** + * The vulnerability is valid but has been mitigated, e.g., measures have been taken to ensure + * this vulnerability can not be exploited. + */ + MITIGATED_VULNERABILITY, + + /** + * The vulnerability was reported, and got a CVE assigned. However, the CVE was later deemed to not be a + * vulnerability. + */ + NOT_A_VULNERABILITY, + + /** + * This vulnerability will never be fixed, e.g., because the package which is affected is orphaned, + * declared end-of-life, or otherwise deprecated. + */ + WILL_NOT_FIX_VULNERABILITY, + + /** + * The vulnerability is valid but a temporary workaround has been put in place to avoid exposure + * to the vulnerability. + */ + WORKAROUND_FOR_VULNERABILITY +} diff --git a/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt b/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt index c276cabe7a..dc784844a9 100644 --- a/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt +++ b/model/src/commonMain/kotlin/VulnerabilityWithDetails.kt @@ -22,7 +22,6 @@ package org.eclipse.apoapsis.ortserver.model import org.eclipse.apoapsis.ortserver.model.runs.Identifier import org.eclipse.apoapsis.ortserver.model.runs.advisor.AdvisorDetails import org.eclipse.apoapsis.ortserver.model.runs.advisor.Vulnerability -import org.eclipse.apoapsis.ortserver.model.runs.repository.VulnerabilityResolution /** * A data class to gather information and related data about a [Vulnerability]. @@ -34,7 +33,10 @@ data class VulnerabilityWithDetails( /** An advisory rating for the [Vulnerability], derived from the individual references of the [Vulnerability]. */ val rating: VulnerabilityRating, - val resolutions: List = emptyList(), + val resolutions: List = emptyList(), + + /** The resolution definitions that match this vulnerability but were not yet available during the run. */ + val newMatchingResolutionDefinitions: List = emptyList(), /** Details about the used advisor. */ val advisor: AdvisorDetails, diff --git a/services/ort-run/src/main/kotlin/OrtRunService.kt b/services/ort-run/src/main/kotlin/OrtRunService.kt index 919ad8540a..20ec4e3592 100644 --- a/services/ort-run/src/main/kotlin/OrtRunService.kt +++ b/services/ort-run/src/main/kotlin/OrtRunService.kt @@ -25,6 +25,7 @@ import kotlinx.datetime.Instant import org.eclipse.apoapsis.ortserver.dao.blockingQuery import org.eclipse.apoapsis.ortserver.dao.dbQuery import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunDao +import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunsTable import org.eclipse.apoapsis.ortserver.dao.tables.NestedRepositoriesTable import org.eclipse.apoapsis.ortserver.dao.tables.shared.VcsInfoDao import org.eclipse.apoapsis.ortserver.model.AdvisorJob @@ -73,6 +74,7 @@ import org.eclipse.apoapsis.ortserver.services.ResourceNotFoundException import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.update import org.ossreviewtoolkit.model.FileList import org.ossreviewtoolkit.model.OrtResult @@ -338,6 +340,17 @@ class OrtRunService( getReporterJobForOrtRun(ortRunId)?.let { reporterRunRepository.getByJobId(it.id) } } + /** + * Return the ID of the repository for the provided [ortRunId] or `null` if the run does not exist. + */ + fun getRepositoryIdForOrtRun(ortRunId: Long) = db.blockingQuery { + OrtRunsTable + .select(OrtRunsTable.repositoryId) + .where { OrtRunsTable.id eq ortRunId } + .singleOrNull() + ?.get(OrtRunsTable.repositoryId)?.value + } + /** * Return the [NotifierJob] for the provided [id] or `null` if the run does not exist. */ @@ -438,6 +451,18 @@ class OrtRunService( ) } + /** + * Mark the ORT runs with the given [ortRunIds] as outdated with the provided [outdatedMessage]. + */ + fun markAsOutdated(ortRunIds: List, outdatedMessage: String) { + db.blockingQuery { + OrtRunsTable.update({ OrtRunsTable.id inList ortRunIds }) { + it[OrtRunsTable.outdated] = true + it[OrtRunsTable.outdatedMessage] = outdatedMessage + } + } + } + /** * Start the [AdvisorJob] with the provided [id] and return the updated job or `null` if the job does not exist. */ diff --git a/services/ort-run/src/main/kotlin/VulnerabilityService.kt b/services/ort-run/src/main/kotlin/VulnerabilityService.kt index 29bb66b614..d59bc5f7ce 100644 --- a/services/ort-run/src/main/kotlin/VulnerabilityService.kt +++ b/services/ort-run/src/main/kotlin/VulnerabilityService.kt @@ -37,18 +37,25 @@ import org.eclipse.apoapsis.ortserver.dao.repositories.ortrun.OrtRunsTable import org.eclipse.apoapsis.ortserver.dao.repositories.repository.RepositoriesTable import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.PackageCurationDataTable import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.PackageCurationsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.RepositoryConfigurationsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.RepositoryConfigurationsVulnerabilityResolutionsTable +import org.eclipse.apoapsis.ortserver.dao.repositories.repositoryconfiguration.VulnerabilityResolutionsTable import org.eclipse.apoapsis.ortserver.dao.repositories.resolvedconfiguration.ResolvedConfigurationsTable import org.eclipse.apoapsis.ortserver.dao.repositories.resolvedconfiguration.ResolvedPackageCurationProvidersTable import org.eclipse.apoapsis.ortserver.dao.repositories.resolvedconfiguration.ResolvedPackageCurationsTable +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable +import org.eclipse.apoapsis.ortserver.dao.tables.VulnerabilityResolutionDefinitionsTable.toVulnerabilityResolutionDefinition import org.eclipse.apoapsis.ortserver.dao.tables.shared.IdentifierDao import org.eclipse.apoapsis.ortserver.dao.tables.shared.IdentifiersTable import org.eclipse.apoapsis.ortserver.dao.utils.applyILike +import org.eclipse.apoapsis.ortserver.model.AppliedVulnerabilityResolution import org.eclipse.apoapsis.ortserver.model.CountByCategory import org.eclipse.apoapsis.ortserver.model.VulnerabilityFilters import org.eclipse.apoapsis.ortserver.model.VulnerabilityForRunsFilters import org.eclipse.apoapsis.ortserver.model.VulnerabilityRating import org.eclipse.apoapsis.ortserver.model.VulnerabilityWithAccumulatedData import org.eclipse.apoapsis.ortserver.model.VulnerabilityWithDetails +import org.eclipse.apoapsis.ortserver.model.runs.repository.VulnerabilityResolution as ModelVulnerabilityResolution import org.eclipse.apoapsis.ortserver.model.util.ComparisonOperator import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult @@ -79,13 +86,14 @@ import org.jetbrains.exposed.sql.stringLiteral import org.jetbrains.exposed.sql.wrapAsExpression import org.ossreviewtoolkit.model.config.VulnerabilityResolution +import org.ossreviewtoolkit.model.config.VulnerabilityResolutionReason import org.ossreviewtoolkit.model.utils.toPurl /** * A service to interact with vulnerabilities. */ class VulnerabilityService(private val db: Database, private val ortRunService: OrtRunService) { - fun listForOrtRunId( + suspend fun listForOrtRunId( ortRunId: Long, parameters: ListQueryParameters = ListQueryParameters.DEFAULT, vulnerabilityFilters: VulnerabilityFilters = VulnerabilityFilters() @@ -159,11 +167,64 @@ class VulnerabilityService(private val db: Database, private val ortRunService: .drop(parameters.offset?.toInt() ?: 0) .take(parameters.limit ?: ListQueryParameters.DEFAULT_LIMIT) + val vulnerabilityResolutionDefinitions = db.dbQuery { + RepositoryConfigurationsVulnerabilityResolutionsTable + .innerJoin(VulnerabilityResolutionsTable) + .innerJoin(RepositoryConfigurationsTable) + .innerJoin(VulnerabilityResolutionDefinitionsTable) + .select( + VulnerabilityResolutionsTable.columns + VulnerabilityResolutionDefinitionsTable.columns + ) + .where { RepositoryConfigurationsTable.ortRunId eq ortRunId } + .map { row -> + row.toVulnerabilityResolutionDefinition() to ModelVulnerabilityResolution( + row[VulnerabilityResolutionsTable.externalId], + row[VulnerabilityResolutionsTable.reason], + row[VulnerabilityResolutionsTable.comment] + ) + } + } + + val newVulnerabilityResolutionDefinitions = db.dbQuery { + VulnerabilityResolutionDefinitionsTable + .select(VulnerabilityResolutionDefinitionsTable.columns) + .where { + (VulnerabilityResolutionDefinitionsTable.repositoryId eq ortRun.repositoryId) and + (VulnerabilityResolutionDefinitionsTable.contextRunId greaterEq ortRun.id) and + (VulnerabilityResolutionDefinitionsTable.archived eq false) + } + .map { row -> row.toVulnerabilityResolutionDefinition() } + } + val vulnerabilitiesWithResolutions = limitedVulnerabilities.map { vulnerabilityWithDetails -> + val matchingNewDefinitions = newVulnerabilityResolutionDefinitions.filter { definition -> + definition.idMatchers.any { idMatcher -> + VulnerabilityResolution( + idMatcher, + VulnerabilityResolutionReason.valueOf(definition.reason.name), + definition.comment + ).matches(vulnerabilityWithDetails.vulnerability.mapToOrt()) + } + } val matchingResolutions = resolutions.filter { it.matches(vulnerabilityWithDetails.vulnerability.mapToOrt()) } - vulnerabilityWithDetails.copy(resolutions = matchingResolutions.map { it.mapToModel() }) + vulnerabilityWithDetails.copy( + resolutions = matchingResolutions.map { + val resolution = it.mapToModel() + + val definition = vulnerabilityResolutionDefinitions + .firstOrNull { (_, value) -> + resolution == value + }?.first + + AppliedVulnerabilityResolution( + resolution, + definition + ) + }, + newMatchingResolutionDefinitions = matchingNewDefinitions + ) } return ListQueryResult( diff --git a/services/ort-run/src/test/kotlin/OrtRunServiceTest.kt b/services/ort-run/src/test/kotlin/OrtRunServiceTest.kt index 542a037077..66f2cc4cc0 100644 --- a/services/ort-run/src/test/kotlin/OrtRunServiceTest.kt +++ b/services/ort-run/src/test/kotlin/OrtRunServiceTest.kt @@ -1381,6 +1381,37 @@ class OrtRunServiceTest : WordSpec({ } } } + + "markAsOutdated" should { + "mark the provided runs as outdated and save the describing message" { + val run1Id = fixtures.createOrtRun().id + val run2Id = fixtures.createOrtRun().id + val run3Id = fixtures.createOrtRun().id + + val outdatedMsg = "Outdated" + + service.markAsOutdated(listOf(run1Id, run3Id), outdatedMsg) + + val run1 = service.getOrtRun(run1Id) + val run2 = service.getOrtRun(run2Id) + val run3 = service.getOrtRun(run3Id) + + run1 shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe outdatedMessage + } + + run2 shouldNotBeNull { + outdated shouldBe false + outdatedMessage shouldBe null + } + + run3 shouldNotBeNull { + outdated shouldBe true + outdatedMessage shouldBe outdatedMessage + } + } + } }) private fun createOrtRun( diff --git a/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt b/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt index dc5e9b216e..f45fc01020 100644 --- a/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt +++ b/services/ort-run/src/test/kotlin/VulnerabilityServiceTest.kt @@ -22,8 +22,11 @@ package org.eclipse.apoapsis.ortserver.services.ortrun import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.collections.beEmpty import io.kotest.matchers.collections.containExactlyInAnyOrder +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldBeSingleton import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.beNull import io.kotest.matchers.should import io.kotest.matchers.shouldBe @@ -38,9 +41,12 @@ import org.eclipse.apoapsis.ortserver.model.AdvisorJobConfiguration import org.eclipse.apoapsis.ortserver.model.JobConfigurations import org.eclipse.apoapsis.ortserver.model.OrtRun import org.eclipse.apoapsis.ortserver.model.PluginConfig +import org.eclipse.apoapsis.ortserver.model.RepositoryId import org.eclipse.apoapsis.ortserver.model.VulnerabilityFilters import org.eclipse.apoapsis.ortserver.model.VulnerabilityForRunsFilters import org.eclipse.apoapsis.ortserver.model.VulnerabilityRating +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason import org.eclipse.apoapsis.ortserver.model.resolvedconfiguration.PackageCurationProviderConfig import org.eclipse.apoapsis.ortserver.model.resolvedconfiguration.ResolvedPackageCurations import org.eclipse.apoapsis.ortserver.model.runs.Environment @@ -272,7 +278,7 @@ class VulnerabilityServiceTest : WordSpec() { with(resolutions) { this shouldHaveSize 1 - this.first() shouldBe vulnerabilityResolution + this.first().resolution shouldBe vulnerabilityResolution } } } @@ -333,7 +339,7 @@ class VulnerabilityServiceTest : WordSpec() { with(resolutions) { this shouldHaveSize 1 - this.first() shouldBe vulnerabilityResolution + this.first().resolution shouldBe vulnerabilityResolution } } @@ -599,6 +605,165 @@ class VulnerabilityServiceTest : WordSpec() { purl shouldBe pkg1.purl } } + + "return the definition of the vulnerability resolution if it originated from the server" { + val run1Id = fixtures.createOrtRun().id + val definitionId = fixtures.createVulnerabilityResolutionDefinition( + contextRunId = run1Id, + idMatchers = listOf("CVE-2021-45046"), + reason = VulnerabilityResolutionReason.MITIGATED_VULNERABILITY + ) + + val run2 = fixtures.createOrtRun() + val advisorJobId = fixtures.createAdvisorJob(run2.id).id + + val vulnerabilities = createVulnerabilities( + Triple( + Identifier("Maven", "org.apache.logging.log4j", "log4j-core", "2.14.0"), + listOf("CVE-2021-45046"), + listOf(10.0) + ), + Triple( + Identifier("Maven", "com.fasterxml.jackson.core", "jackson-databind", "2.9.6"), + listOf("CVE-2018-14721"), + listOf(4.2) + ), + Triple( + Identifier("Maven", "junit", "junit", "1.0"), + listOf("CVE-2024-24521"), + listOf(5.0) + ) + ) + + fixtures.createAdvisorRun(advisorJobId, createAdvisorResults(vulnerabilities)) + + val vulnerabilityResolution = VulnerabilityResolution( + "CVE-2024-24521", + "INEFFECTIVE_VULNERABILITY", + "Comment." + ) + + val repositoryConfiguration = + fixtures.createRepositoryConfiguration(run2.id, listOf(vulnerabilityResolution)) + + fixtures.resolvedConfigurationRepository.addResolutions(run2.id, repositoryConfiguration.resolutions) + + val results = service.listForOrtRunId(run2.id) + + results.totalCount shouldBe 3 + + with(results.data[0]) { + vulnerability.externalId shouldBe "CVE-2018-14721" + resolutions.shouldBeEmpty() + } + + with(results.data[1]) { + vulnerability.externalId shouldBe "CVE-2021-45046" + + resolutions.shouldBeSingleton { + it.resolution shouldBe VulnerabilityResolution( + "CVE-2021-45046", + "MITIGATED_VULNERABILITY", + "Comment." + ) + + it.definition shouldBe VulnerabilityResolutionDefinition( + id = definitionId, + hierarchyId = RepositoryId(run2.repositoryId), + contextRunId = run1Id, + idMatchers = listOf("CVE-2021-45046"), + reason = VulnerabilityResolutionReason.MITIGATED_VULNERABILITY, + comment = "Comment.", + archived = false, + changes = emptyList() + ) + } + } + + with(results.data[2]) { + vulnerability.externalId shouldBe "CVE-2024-24521" + + resolutions.shouldBeSingleton { + it.resolution shouldBe vulnerabilityResolution + it.definition should beNull() + } + } + } + + "return new matching definitions that have been created after the run" { + val run = fixtures.createOrtRun() + val advisorJobId = fixtures.createAdvisorJob(run.id).id + + val vulnerabilities = createVulnerabilities( + Triple( + Identifier("Maven", "org.apache.logging.log4j", "log4j-core", "2.14.0"), + listOf("CVE-2021-45046"), + listOf(10.0) + ), + Triple( + Identifier("Maven", "com.fasterxml.jackson.core", "jackson-databind", "2.9.6"), + listOf("CVE-2018-14721"), + listOf(4.2) + ), + Triple( + Identifier("Maven", "junit", "junit", "1.0"), + listOf("CVE-2024-24521"), + listOf(5.0) + ) + ) + + fixtures.createAdvisorRun(advisorJobId, createAdvisorResults(vulnerabilities)) + + val definitionId = fixtures.createVulnerabilityResolutionDefinition( + contextRunId = run.id, + idMatchers = listOf("CVE-2021-45046", "CVE-2024-24521"), + reason = VulnerabilityResolutionReason.WORKAROUND_FOR_VULNERABILITY + ) + + val results = service.listForOrtRunId(run.id) + + results.totalCount shouldBe 3 + + with(results.data[0]) { + vulnerability.externalId shouldBe "CVE-2018-14721" + resolutions.shouldBeEmpty() + newMatchingResolutionDefinitions.shouldBeEmpty() + } + + with(results.data[1]) { + vulnerability.externalId shouldBe "CVE-2021-45046" + resolutions.shouldBeEmpty() + newMatchingResolutionDefinitions shouldHaveSize 1 + + newMatchingResolutionDefinitions.first() shouldBe VulnerabilityResolutionDefinition( + id = definitionId, + hierarchyId = RepositoryId(run.repositoryId), + contextRunId = run.id, + idMatchers = listOf("CVE-2021-45046", "CVE-2024-24521"), + reason = VulnerabilityResolutionReason.WORKAROUND_FOR_VULNERABILITY, + comment = "Comment.", + archived = false, + changes = emptyList() + ) + } + + with(results.data[2]) { + vulnerability.externalId shouldBe "CVE-2024-24521" + resolutions.shouldBeEmpty() + newMatchingResolutionDefinitions shouldHaveSize 1 + + newMatchingResolutionDefinitions.first() shouldBe VulnerabilityResolutionDefinition( + id = definitionId, + hierarchyId = RepositoryId(run.repositoryId), + contextRunId = run.id, + idMatchers = listOf("CVE-2021-45046", "CVE-2024-24521"), + reason = VulnerabilityResolutionReason.WORKAROUND_FOR_VULNERABILITY, + comment = "Comment.", + archived = false, + changes = emptyList() + ) + } + } } "countForOrtRunId" should { diff --git a/settings.gradle.kts b/settings.gradle.kts index 02fc16fe7c..cc06f0b62c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,8 @@ include(":components:infrastructure-services:api-model") include(":components:infrastructure-services:backend") include(":components:plugin-manager:api-model") include(":components:plugin-manager:backend") +include(":components:resolutions:api-model") +include(":components:resolutions:backend") include(":components:secrets:api-model") include(":components:secrets:backend") include(":compositions:secrets-routes") diff --git a/shared/api-mappings/src/commonMain/kotlin/ChangeEventMappings.kt b/shared/api-mappings/src/commonMain/kotlin/ChangeEventMappings.kt new file mode 100644 index 0000000000..3b922650a0 --- /dev/null +++ b/shared/api-mappings/src/commonMain/kotlin/ChangeEventMappings.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimappings + +import org.eclipse.apoapsis.ortserver.model.ChangeEvent +import org.eclipse.apoapsis.ortserver.model.ChangeEventAction +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEvent as ApiChangeEvent +import org.eclipse.apoapsis.ortserver.shared.apimodel.ChangeEventAction as ApiChangeEventAction + +fun ChangeEvent.mapToApi() = ApiChangeEvent( + user = user.mapToApi(), + occurredAt = occurredAt, + action = action.mapToApi() + ) + +fun ChangeEventAction.mapToApi() = ApiChangeEventAction.valueOf(name) diff --git a/shared/api-mappings/src/commonMain/kotlin/UserDisplayNameMappings.kt b/shared/api-mappings/src/commonMain/kotlin/UserDisplayNameMappings.kt new file mode 100644 index 0000000000..ba84083f7a --- /dev/null +++ b/shared/api-mappings/src/commonMain/kotlin/UserDisplayNameMappings.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimappings + +import org.eclipse.apoapsis.ortserver.model.UserDisplayName +import org.eclipse.apoapsis.ortserver.shared.apimodel.UserDisplayName as ApiUserDisplayName + +fun UserDisplayName.mapToApi() = ApiUserDisplayName(username = username, fullName = fullName) diff --git a/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionDefinitionMappings.kt b/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionDefinitionMappings.kt new file mode 100644 index 0000000000..c195402f47 --- /dev/null +++ b/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionDefinitionMappings.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimappings + +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionDefinition +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionDefinition as ApiVulnerabilityResolutionDefinition + +fun VulnerabilityResolutionDefinition.mapToApi() = + ApiVulnerabilityResolutionDefinition( + id = id, + idMatchers = idMatchers, + reason = reason.mapToApi(), + comment = comment, + archived = archived, + changes = changes.map { it.mapToApi() } + ) diff --git a/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionReasonMappings.kt b/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionReasonMappings.kt new file mode 100644 index 0000000000..589fbf1090 --- /dev/null +++ b/shared/api-mappings/src/commonMain/kotlin/VulnerabilityResolutionReasonMappings.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimappings + +import org.eclipse.apoapsis.ortserver.model.VulnerabilityResolutionReason +import org.eclipse.apoapsis.ortserver.shared.apimodel.VulnerabilityResolutionReason as ApiVulnerabilityResolutionReason + +fun VulnerabilityResolutionReason.mapToApi() = ApiVulnerabilityResolutionReason.valueOf(name) + +fun ApiVulnerabilityResolutionReason.mapToModel() = VulnerabilityResolutionReason.valueOf(name) diff --git a/shared/api-model/build.gradle.kts b/shared/api-model/build.gradle.kts index 1947e5ab93..e8f8d43006 100644 --- a/shared/api-model/build.gradle.kts +++ b/shared/api-model/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { sourceSets { commonMain { dependencies { + implementation(libs.kotlinxDatetime) implementation(libs.kotlinxSerializationJson) } } diff --git a/shared/api-model/src/commonMain/kotlin/ChangeEvent.kt b/shared/api-model/src/commonMain/kotlin/ChangeEvent.kt new file mode 100644 index 0000000000..0e781ee92c --- /dev/null +++ b/shared/api-model/src/commonMain/kotlin/ChangeEvent.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimodel + +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +/** + * A change event performed by a user. + */ +@Serializable +data class ChangeEvent( + /** The user who performed the change. */ + val user: UserDisplayName, + + /** The time the change occurred. */ + val occurredAt: Instant, + + /** The action performed. */ + val action: ChangeEventAction +) + +/** + * An enumeration of the actions that can be performed, resulting in a [ChangeEvent]. + */ +@Serializable +enum class ChangeEventAction { + /** The creation of a new entity. */ + CREATE, + + /** The update of an existing entity. */ + UPDATE, + + /** The archival, i.e. soft deletion, of an existing entity. */ + ARCHIVE, + + /** The restoration, i.e. un-archival, of an archived entity. */ + RESTORE +} diff --git a/api/v1/model/src/commonMain/kotlin/UserDisplayName.kt b/shared/api-model/src/commonMain/kotlin/UserDisplayName.kt similarity index 96% rename from api/v1/model/src/commonMain/kotlin/UserDisplayName.kt rename to shared/api-model/src/commonMain/kotlin/UserDisplayName.kt index 7ce6175c10..00ccdd8c70 100644 --- a/api/v1/model/src/commonMain/kotlin/UserDisplayName.kt +++ b/shared/api-model/src/commonMain/kotlin/UserDisplayName.kt @@ -17,7 +17,7 @@ * License-Filename: LICENSE */ -package org.eclipse.apoapsis.ortserver.api.v1.model +package org.eclipse.apoapsis.ortserver.shared.apimodel import kotlinx.serialization.Serializable diff --git a/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt b/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt new file mode 100644 index 0000000000..90e45c6901 --- /dev/null +++ b/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionDefinition.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimodel + +import kotlinx.serialization.Serializable + +/* + * The response object for a vulnerability resolution definition. + */ +@Serializable +data class VulnerabilityResolutionDefinition( + /** The unique identifier of the vulnerability resolution definition. */ + val id: Long, + + /** + * The list of vulnerability ID matchers (regular expressions) to match the ids of the vulnerabilities to resolve. + */ + val idMatchers: List, + + /** The reason why the vulnerability is resolved. */ + val reason: VulnerabilityResolutionReason, + + /** A comment to further explain why the [reason] is applicable here. */ + val comment: String, + + /** Whether the vulnerability resolution definition is archived. */ + val archived: Boolean, + + /** The list of change events associated with this vulnerability resolution definition. */ + val changes: List +) diff --git a/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt b/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt new file mode 100644 index 0000000000..df69249287 --- /dev/null +++ b/shared/api-model/src/commonMain/kotlin/VulnerabilityResolutionReason.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2025 The ORT Server Authors (See ) + * + * 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 + * + * https://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 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.shared.apimodel + +import kotlinx.serialization.Serializable + +/** + * Possible reasons for resolving an [Vulnerability] using a [VulnerabilityResolution]. + */ +@Serializable +enum class VulnerabilityResolutionReason { + /** + * No remediation is available for this vulnerability, e.g., because it requires a change to be made + * by a third party that is not responsive. + */ + CANT_FIX_VULNERABILITY, + + /** + * The code in which the vulnerability was found is neither invoked in the project's code nor indirectly + * via another open source component. + */ + INEFFECTIVE_VULNERABILITY, + + /** + * The vulnerability is irrelevant due to a tooling or database mismatch, e.g., the package version used + * does not match the version for which the vulnerability provider has reported a vulnerability. + */ + INVALID_MATCH_VULNERABILITY, + + /** + * The vulnerability is valid but has been mitigated, e.g., measures have been taken to ensure + * this vulnerability can not be exploited. + */ + MITIGATED_VULNERABILITY, + + /** + * The vulnerability was reported, and got a CVE assigned. However, the CVE was later deemed to not be a + * vulnerability. + */ + NOT_A_VULNERABILITY, + + /** + * This vulnerability will never be fixed, e.g., because the package which is affected is orphaned, + * declared end-of-life, or otherwise deprecated. + */ + WILL_NOT_FIX_VULNERABILITY, + + /** + * The vulnerability is valid but a temporary workaround has been put in place to avoid exposure + * to the vulnerability. + */ + WORKAROUND_FOR_VULNERABILITY +}