diff --git a/adapter/build.gradle.kts b/adapter/build.gradle.kts index b1f396d..4eb8f2d 100644 --- a/adapter/build.gradle.kts +++ b/adapter/build.gradle.kts @@ -9,10 +9,15 @@ plugins { id("spha-kotlin-conventions") + alias(libs.plugins.serialization) } group = "de.fraunhofer.iem.kpiCalculator" dependencies { implementation(project(":model")) + implementation(libs.kotlin.serialization.json) + + testImplementation(libs.test.junit5.params) + testImplementation(libs.test.mockk) } diff --git a/adapter/src/main/kotlin/de/fraunhofer/iem/kpiCalculator/adapter/tools/trivy/TrivyAdapter.kt b/adapter/src/main/kotlin/de/fraunhofer/iem/kpiCalculator/adapter/tools/trivy/TrivyAdapter.kt new file mode 100644 index 0000000..6dd528a --- /dev/null +++ b/adapter/src/main/kotlin/de/fraunhofer/iem/kpiCalculator/adapter/tools/trivy/TrivyAdapter.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2024 Fraunhofer IEM. All rights reserved. + * + * Licensed under the MIT license. See LICENSE file in the project root for details. + * + * SPDX-License-Identifier: MIT + * License-Filename: LICENSE + */ + +package de.fraunhofer.iem.kpiCalculator.adapter.tools.trivy + +import de.fraunhofer.iem.kpiCalculator.adapter.AdapterResult +import de.fraunhofer.iem.kpiCalculator.adapter.KpiAdapter +import de.fraunhofer.iem.kpiCalculator.adapter.kpis.cve.CveAdapter +import de.fraunhofer.iem.kpiCalculator.model.adapter.trivy.* +import de.fraunhofer.iem.kpiCalculator.model.adapter.vulnerability.VulnerabilityDto +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.* +import java.io.InputStream +import kotlin.math.max + +object TrivyAdapter : KpiAdapter { + + private val logger = KotlinLogging.logger {} + + private val jsonParser = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + override fun transformDataToKpi(data: Collection): Collection { + return CveAdapter.transformDataToKpi(data.flatMap { it.Vulnerabilities }) + } + + @OptIn(ExperimentalSerializationApi::class) + fun dtoFromJson(jsonData: InputStream): TrivyDto { + val json = Json.decodeFromStream(jsonData) + + if (json is JsonArray) + return parseV1(json) + else if (json !is JsonObject) + throw UnsupportedOperationException("The provided Trivy result is not supported.") + + val schemaVersion = json.get("SchemaVersion")?.jsonPrimitive?.intOrNull + + if (schemaVersion == 2) + return parseV2(json) + + throw UnsupportedOperationException("Trivy results for schema version '$schemaVersion' are currently not supported.") + } + + private fun parseV1(json: JsonArray) : TrivyDto { + logger.info { "Processing Trivy result from version 0.19.0 or earlier." } + val v1dto = jsonParser.decodeFromJsonElement>(json) + val vulnerabilities = createVulnerabilitiesDto(v1dto.flatMap { it.Vulnerabilities }) + return TrivyDto(vulnerabilities) + } + + private fun parseV2(json: JsonObject): TrivyDto { + logger.info { "Processing Trivy result of SchemaVersion: 2" } + val v2dto = jsonParser.decodeFromJsonElement(json) + val vulnerabilities = createVulnerabilitiesDto(v2dto.Results.flatMap { it.Vulnerabilities }) + return TrivyDto(vulnerabilities) + } + + /*** + * Transforms a collection of Trivy-specific vulnerabilities into the generalized vulnerability format. + * Trivy allows to annotate multiple CVSS scores to a vulnerability entry (e.g, CVSS2 or CVSS3 or even vendor specific). + * This transformation always selects the highest available score for each vulnerability. + */ + private fun createVulnerabilitiesDto(vulnerabilities: Collection) : Collection { + return vulnerabilities + .mapNotNull { + if (it.CVSS == null) { + logger.debug { "Reported vulnerability '${it.VulnerabilityID}' does not have a score. Skipping!" } + return@mapNotNull null + } + + val cvssData = it.CVSS!!.values.map { + jsonParser.decodeFromJsonElement(it) + } + + val score = getHighestCvssScore(cvssData) + logger.trace { "Selected CVSS score $score for vulnerability '${it.VulnerabilityID}'" } + VulnerabilityDto(it.VulnerabilityID, it.PkgID, score) + } + } + + private fun getHighestCvssScore(scores: Collection) : Double { + // NB: If no value was coded we simply return 0.0 (no vulnerability) + // In practice this should never happen + var v2Score = 0.0 + var v3Score = 0.0 + + for (data in scores) { + if (data.V2Score != null) + v2Score = max(v2Score, data.V2Score!!) + + if (data.V3Score != null) + v3Score = max(v3Score, data.V3Score!!) + } + + return max(v2Score, v3Score) + } +} diff --git a/adapter/src/test/kotlin/de/fraunhofer/iem/kpiCalculator/adapter/tools/trivy/TrivyAdapterTest.kt b/adapter/src/test/kotlin/de/fraunhofer/iem/kpiCalculator/adapter/tools/trivy/TrivyAdapterTest.kt new file mode 100644 index 0000000..8f13144 --- /dev/null +++ b/adapter/src/test/kotlin/de/fraunhofer/iem/kpiCalculator/adapter/tools/trivy/TrivyAdapterTest.kt @@ -0,0 +1,80 @@ +package de.fraunhofer.iem.kpiCalculator.adapter.tools.trivy + +import de.fraunhofer.iem.kpiCalculator.adapter.kpis.cve.CveAdapter +import de.fraunhofer.iem.kpiCalculator.model.adapter.trivy.TrivyDto +import de.fraunhofer.iem.kpiCalculator.model.adapter.vulnerability.VulnerabilityDto +import io.mockk.mockkObject +import io.mockk.verify +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.nio.file.Files +import kotlin.io.path.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TrivyAdapterTest { + + @ParameterizedTest + @ValueSource(strings = [ + "{}", // No schema + "{\"SchemaVersion\": 3}" // Not supported schema + ]) + fun testInvalidJson(input: String) { + input.byteInputStream().use { + assertThrows { TrivyAdapter.dtoFromJson(it) } + } + } + + @ParameterizedTest + @ValueSource(strings = [ + "[]", + "{\"SchemaVersion\": 2}" + ]) + fun testEmptyDto(input: String){ + input.byteInputStream().use { + val dto = TrivyAdapter.dtoFromJson(it) + assertEquals(0, dto.Vulnerabilities.count()) + } + } + + @Test + fun testResult2Dto(){ + Files.newInputStream(Path("src/test/resources/trivy-result-v2.json")).use { + val dto = assertDoesNotThrow { TrivyAdapter.dtoFromJson(it) } + assertEquals(1, dto.Vulnerabilities.count()) + + val vuln = dto.Vulnerabilities.first() + assertEquals("CVE-2011-3374", vuln.cveIdentifier) + assertEquals("apt@2.6.1", vuln.packageName) + assertEquals(4.3, vuln.severity) + + } + } + + @Test + fun testResult1Dto(){ + Files.newInputStream(Path("src/test/resources/trivy-result-v1.json")).use { + val dto = assertDoesNotThrow { TrivyAdapter.dtoFromJson(it) } + assertEquals(2, dto.Vulnerabilities.count()) + + assertTrue { dto.Vulnerabilities.all { it.cveIdentifier == "CVE-2005-2541" } } + assertEquals("tar@1.34+dfsg-1.2", dto.Vulnerabilities.first().packageName) + assertEquals(10.0, dto.Vulnerabilities.first().severity) + } + } + + @Test + fun testDto2Kpi_VerifyCveAdapterGetsCalled() { + mockkObject(CveAdapter) + val vulns = listOf( + VulnerabilityDto("CVE-1", "A", 1.0), + VulnerabilityDto("CVE-2", "B", 2.0), + VulnerabilityDto("CVE-3", "C", 1.3), + ) + TrivyAdapter.transformDataToKpi(listOf(TrivyDto(vulns))) + verify { CveAdapter.transformDataToKpi(vulns) } + } +} diff --git a/adapter/src/test/resources/trivy-result-v1.json b/adapter/src/test/resources/trivy-result-v1.json new file mode 100644 index 0000000..a8ca270 --- /dev/null +++ b/adapter/src/test/resources/trivy-result-v1.json @@ -0,0 +1,162 @@ +[ + { + "Target": "alpine 3.12", + "Type": "alpine", + "Vulnerabilities": [ + { + "VulnerabilityID": "TEMP-0517018-A83CE6", + "PkgID": "sysvinit-utils@3.06-4", + "PkgName": "sysvinit-utils", + "PkgIdentifier": { + "PURL": "pkg:deb/debian/sysvinit-utils@3.06-4?arch=amd64\u0026distro=debian-12.2", + "UID": "a95815274a3d74d8" + }, + "InstalledVersion": "3.06-4", + "Status": "affected", + "Layer": { + "DiffID": "sha256:7cea17427f83f6c4706c74f94fb6d7925b06ea9a0701234f1a9d43f6af11432a" + }, + "SeveritySource": "debian", + "PrimaryURL": "https://security-tracker.debian.org/tracker/TEMP-0517018-A83CE6", + "DataSource": { + "ID": "debian", + "Name": "Debian Security Tracker", + "URL": "https://salsa.debian.org/security-tracker-team/security-tracker" + }, + "Title": "[sysvinit: no-root option in expert installer exposes locally exploitable security flaw]", + "Severity": "LOW", + "VendorSeverity": { + "debian": 1 + } + }, + { + "VulnerabilityID": "CVE-2005-2541", + "PkgID": "tar@1.34+dfsg-1.2", + "PkgName": "tar", + "PkgIdentifier": { + "PURL": "pkg:deb/debian/tar@1.34%2Bdfsg-1.2?arch=amd64\u0026distro=debian-12.2", + "UID": "efe23db0e46e9a72" + }, + "InstalledVersion": "1.34+dfsg-1.2", + "Status": "affected", + "Layer": { + "DiffID": "sha256:7cea17427f83f6c4706c74f94fb6d7925b06ea9a0701234f1a9d43f6af11432a" + }, + "SeveritySource": "debian", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2005-2541", + "DataSource": { + "ID": "debian", + "Name": "Debian Security Tracker", + "URL": "https://salsa.debian.org/security-tracker-team/security-tracker" + }, + "Title": "tar: does not properly warn the user when extracting setuid or setgid files", + "Description": "Tar 1.15.1 does not properly warn the user when extracting setuid or setgid files, which may allow local users or remote attackers to gain privileges.", + "Severity": "LOW", + "VendorSeverity": { + "debian": 1, + "nvd": 3, + "redhat": 2 + }, + "CVSS": { + "nvd": { + "V2Vector": "AV:N/AC:L/Au:N/C:C/I:C/A:C", + "V2Score": 10 + }, + "redhat": { + "V3Vector": "CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H", + "V3Score": 7 + } + }, + "References": [ + "http://marc.info/?l=bugtraq\u0026m=112327628230258\u0026w=2", + "https://access.redhat.com/security/cve/CVE-2005-2541", + "https://lists.apache.org/thread.html/rc713534b10f9daeee2e0990239fa407e2118e4aa9e88a7041177497c%40%3Cissues.guacamole.apache.org%3E", + "https://nvd.nist.gov/vuln/detail/CVE-2005-2541", + "https://www.cve.org/CVERecord?id=CVE-2005-2541" + ], + "PublishedDate": "2005-08-10T04:00:00Z", + "LastModifiedDate": "2023-11-07T01:57:39.453Z" + } + ] + }, + { + "Target": "app/jenkins.jar", + "Type": "java", + "Vulnerabilities": [ + { + "VulnerabilityID": "TEMP-0517018-A83CE6", + "PkgID": "sysvinit-utils@3.06-4", + "PkgName": "sysvinit-utils", + "PkgIdentifier": { + "PURL": "pkg:deb/debian/sysvinit-utils@3.06-4?arch=amd64\u0026distro=debian-12.2", + "UID": "a95815274a3d74d8" + }, + "InstalledVersion": "3.06-4", + "Status": "affected", + "Layer": { + "DiffID": "sha256:7cea17427f83f6c4706c74f94fb6d7925b06ea9a0701234f1a9d43f6af11432a" + }, + "SeveritySource": "debian", + "PrimaryURL": "https://security-tracker.debian.org/tracker/TEMP-0517018-A83CE6", + "DataSource": { + "ID": "debian", + "Name": "Debian Security Tracker", + "URL": "https://salsa.debian.org/security-tracker-team/security-tracker" + }, + "Title": "[sysvinit: no-root option in expert installer exposes locally exploitable security flaw]", + "Severity": "LOW", + "VendorSeverity": { + "debian": 1 + } + }, + { + "VulnerabilityID": "CVE-2005-2541", + "PkgID": "tar@1.34+dfsg-1.2", + "PkgName": "tar", + "PkgIdentifier": { + "PURL": "pkg:deb/debian/tar@1.34%2Bdfsg-1.2?arch=amd64\u0026distro=debian-12.2", + "UID": "efe23db0e46e9a72" + }, + "InstalledVersion": "1.34+dfsg-1.2", + "Status": "affected", + "Layer": { + "DiffID": "sha256:7cea17427f83f6c4706c74f94fb6d7925b06ea9a0701234f1a9d43f6af11432a" + }, + "SeveritySource": "debian", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2005-2541", + "DataSource": { + "ID": "debian", + "Name": "Debian Security Tracker", + "URL": "https://salsa.debian.org/security-tracker-team/security-tracker" + }, + "Title": "tar: does not properly warn the user when extracting setuid or setgid files", + "Description": "Tar 1.15.1 does not properly warn the user when extracting setuid or setgid files, which may allow local users or remote attackers to gain privileges.", + "Severity": "LOW", + "VendorSeverity": { + "debian": 1, + "nvd": 3, + "redhat": 2 + }, + "CVSS": { + "nvd": { + "V2Vector": "AV:N/AC:L/Au:N/C:C/I:C/A:C", + "V2Score": 10 + }, + "redhat": { + "V3Vector": "CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H", + "V3Score": 7 + } + }, + "References": [ + "http://marc.info/?l=bugtraq\u0026m=112327628230258\u0026w=2", + "https://access.redhat.com/security/cve/CVE-2005-2541", + "https://lists.apache.org/thread.html/rc713534b10f9daeee2e0990239fa407e2118e4aa9e88a7041177497c%40%3Cissues.guacamole.apache.org%3E", + "https://nvd.nist.gov/vuln/detail/CVE-2005-2541", + "https://www.cve.org/CVERecord?id=CVE-2005-2541" + ], + "PublishedDate": "2005-08-10T04:00:00Z", + "LastModifiedDate": "2023-11-07T01:57:39.453Z" + } + ] + } +] diff --git a/adapter/src/test/resources/trivy-result-v2.json b/adapter/src/test/resources/trivy-result-v2.json new file mode 100644 index 0000000..9b70adb --- /dev/null +++ b/adapter/src/test/resources/trivy-result-v2.json @@ -0,0 +1,134 @@ +{ + "SchemaVersion": 2, + "CreatedAt": "2024-08-07T16:02:08.849248645+02:00", + "ArtifactName": "/home/redacted/temp/debian.tar", + "ArtifactType": "container_image", + "Metadata": { + "OS": { + "Family": "debian", + "Name": "12.2" + }, + "ImageID": "sha256:0ce03c8a15ec97f121b394857119e3e7652bba5a66845cbfa449d87a5251914e", + "DiffIDs": [ + "sha256:7cea17427f83f6c4706c74f94fb6d7925b06ea9a0701234f1a9d43f6af11432a" + ], + "ImageConfig": { + "architecture": "amd64", + "container": "e4e33ed2cb6e48007d2e078d123c65893ade2d35c4aaef596116624b506cbeff", + "created": "2023-11-21T05:21:25.128983079Z", + "docker_version": "20.10.23", + "history": [ + { + "created": "2023-11-21T05:21:24.536066751Z", + "created_by": "/bin/sh -c #(nop) ADD file:39d17d28c5de0bd629e5b7c8190228e5a445d61d668e189b7523e90e68f78244 in / " + }, + { + "created": "2023-11-21T05:21:25.128983079Z", + "created_by": "/bin/sh -c #(nop) CMD [\"bash\"]", + "empty_layer": true + } + ], + "os": "linux", + "rootfs": { + "type": "layers", + "diff_ids": [ + "sha256:7cea17427f83f6c4706c74f94fb6d7925b06ea9a0701234f1a9d43f6af11432a" + ] + }, + "config": { + "Cmd": [ + "bash" + ], + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ], + "Image": "sha256:7612c528f44e756a6c27b9f5fc2d706d448363a675e79e6d087704349dc45132" + } + } + }, + "Results": [ + { + "Target": "/home/redacted/temp/debian.tar (debian 12.2)", + "Class": "os-pkgs", + "Type": "debian", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2011-3374", + "PkgID": "apt@2.6.1", + "PkgName": "apt", + "PkgIdentifier": { + "PURL": "pkg:deb/debian/apt@2.6.1?arch=amd64\u0026distro=debian-12.2", + "UID": "5c7eeccd7b3c29f8" + }, + "InstalledVersion": "2.6.1", + "Status": "affected", + "Layer": { + "DiffID": "sha256:7cea17427f83f6c4706c74f94fb6d7925b06ea9a0701234f1a9d43f6af11432a" + }, + "SeveritySource": "debian", + "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2011-3374", + "DataSource": { + "ID": "debian", + "Name": "Debian Security Tracker", + "URL": "https://salsa.debian.org/security-tracker-team/security-tracker" + }, + "Title": "It was found that apt-key in apt, all versions, do not correctly valid ...", + "Description": "It was found that apt-key in apt, all versions, do not correctly validate gpg keys with the master keyring, leading to a potential man-in-the-middle attack.", + "Severity": "LOW", + "CweIDs": [ + "CWE-347" + ], + "VendorSeverity": { + "debian": 1, + "nvd": 1 + }, + "CVSS": { + "nvd": { + "V2Vector": "AV:N/AC:M/Au:N/C:N/I:P/A:N", + "V3Vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N", + "V2Score": 4.3, + "V3Score": 3.7 + } + }, + "References": [ + "https://access.redhat.com/security/cve/cve-2011-3374", + "https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=642480", + "https://people.canonical.com/~ubuntu-security/cve/2011/CVE-2011-3374.html", + "https://seclists.org/fulldisclosure/2011/Sep/221", + "https://security-tracker.debian.org/tracker/CVE-2011-3374", + "https://snyk.io/vuln/SNYK-LINUX-APT-116518", + "https://ubuntu.com/security/CVE-2011-3374" + ], + "PublishedDate": "2019-11-26T00:15:11.03Z", + "LastModifiedDate": "2021-02-09T16:08:18.683Z" + }, + { + "VulnerabilityID": "TEMP-0841856-B18BAF", + "PkgID": "bash@5.2.15-2+b2", + "PkgName": "bash", + "PkgIdentifier": { + "PURL": "pkg:deb/debian/bash@5.2.15-2%2Bb2?arch=amd64\u0026distro=debian-12.2", + "UID": "9d49b264ef97be41" + }, + "InstalledVersion": "5.2.15-2+b2", + "Status": "affected", + "Layer": { + "DiffID": "sha256:7cea17427f83f6c4706c74f94fb6d7925b06ea9a0701234f1a9d43f6af11432a" + }, + "SeveritySource": "debian", + "PrimaryURL": "https://security-tracker.debian.org/tracker/TEMP-0841856-B18BAF", + "DataSource": { + "ID": "debian", + "Name": "Debian Security Tracker", + "URL": "https://salsa.debian.org/security-tracker-team/security-tracker" + }, + "Title": "[Privilege escalation possible to other user than root]", + "Severity": "LOW", + "VendorSeverity": { + "debian": 1 + } + } + ] + } + ] +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3b98667..0ae6d18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,8 @@ kotlinxSerialization = "1.7.1" kotlinPlugin = "2.0.20" kotlinLogging = "7.0.0" slf4jApi = "2.0.16" +junit = "5.11.0" +mockk = "1.13.12" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -13,6 +15,10 @@ slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" } slf4j-logger = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jApi" } plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlinPlugin" } +test-junit5-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } +test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } + + [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/model/src/main/kotlin/de/fraunhofer/iem/kpiCalculator/model/adapter/trivy/TrivyDto.kt b/model/src/main/kotlin/de/fraunhofer/iem/kpiCalculator/model/adapter/trivy/TrivyDto.kt new file mode 100644 index 0000000..50db215 --- /dev/null +++ b/model/src/main/kotlin/de/fraunhofer/iem/kpiCalculator/model/adapter/trivy/TrivyDto.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 Fraunhofer IEM. All rights reserved. + * + * Licensed under the MIT license. See LICENSE file in the project root for details. + * + * SPDX-License-Identifier: MIT + * License-Filename: LICENSE + */ + +package de.fraunhofer.iem.kpiCalculator.model.adapter.trivy + +import de.fraunhofer.iem.kpiCalculator.model.adapter.vulnerability.VulnerabilityDto +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +data class TrivyDto(val Vulnerabilities: Collection) + +@Serializable +data class TrivyDtoV1( + val Vulnerabilities: List = listOf() +) + +@Serializable +data class TrivyDtoV2( + val Results: List = listOf(), + val SchemaVersion: Int +) + +@Serializable +data class Result( + val Vulnerabilities: List = listOf() +) + +@Serializable +data class TrivyVulnerabilityDto( + // NB: Because the names of its inner elements are not fixed, this needs to be a JsonObject. + // This way we can iterate over those when required. Their type is always CVSSData. + val CVSS: JsonObject?, + val VulnerabilityID: String, + val PkgID: String +) + +@Serializable +data class CVSSData( + val V2Score: Double?, + val V3Score: Double?, +)