Skip to content

Commit b5cc0ea

Browse files
committed
feat(advisor): Centrally normalize vulnerability data
There are more advisors than VulnerableCode that report "MODERATE" instead of "MEDIUM" as a severity. To address this, generally normalize vulnerability data independently of the advisor. This guarantees uniform data for more convenient use in rules and other places that implement conditions based on e.g. the severity, and results in a more consistent experience when displaying results in a UI. Signed-off-by: Sebastian Schuberth <sebastian@doubleopen.org>
1 parent 254809a commit b5cc0ea

File tree

10 files changed

+79
-45
lines changed

10 files changed

+79
-45
lines changed

advisor/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies {
2929

3030
implementation(projects.utils.ortUtils)
3131

32+
implementation(libs.aeSecurity)
3233
implementation(libs.kotlinx.coroutines)
3334

3435
testImplementation(libs.mockk)

advisor/src/main/kotlin/Advisor.kt

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,16 @@ import kotlinx.coroutines.withContext
2727

2828
import org.apache.logging.log4j.kotlin.logger
2929

30+
import org.metaeffekt.core.security.cvss.CvssVector
31+
3032
import org.ossreviewtoolkit.model.AdvisorResult
3133
import org.ossreviewtoolkit.model.AdvisorRun
3234
import org.ossreviewtoolkit.model.Identifier
3335
import org.ossreviewtoolkit.model.OrtResult
3436
import org.ossreviewtoolkit.model.Package
3537
import org.ossreviewtoolkit.model.config.AdvisorConfiguration
38+
import org.ossreviewtoolkit.model.vulnerabilities.Vulnerability
39+
import org.ossreviewtoolkit.model.vulnerabilities.VulnerabilityReference
3640
import org.ossreviewtoolkit.plugins.api.PluginConfig
3741
import org.ossreviewtoolkit.utils.ort.Environment
3842

@@ -101,7 +105,9 @@ class Advisor(
101105
// Merge results from different providers into a single map keyed by the package ID. The original
102106
// provider is still maintained as part of the AdvisorResult's AdvisorDetails.
103107
providerResults.await().forEach { (pkg, advisorResults) ->
104-
results.merge(pkg.id, listOf(advisorResults)) { existingResults, additionalResults ->
108+
val normalizedResults = advisorResults.normalizeVulnerabilityData()
109+
110+
results.merge(pkg.id, listOf(normalizedResults)) { existingResults, additionalResults ->
105111
existingResults + additionalResults
106112
}
107113
}
@@ -113,3 +119,37 @@ class Advisor(
113119
AdvisorRun(startTime, endTime, Environment(), config, results)
114120
}
115121
}
122+
123+
fun AdvisorResult.normalizeVulnerabilityData(): AdvisorResult =
124+
copy(vulnerabilities = vulnerabilities.normalizeVulnerabilityData())
125+
126+
fun List<Vulnerability>.normalizeVulnerabilityData(): List<Vulnerability> =
127+
map { vulnerability ->
128+
val normalizedReferences = vulnerability.references.map { reference ->
129+
reference
130+
.run {
131+
// Treat "MODERATE" as an alias for "MEDIUM" independently of the scoring system.
132+
if (severity == "MODERATE") copy(severity = "MEDIUM") else this
133+
}
134+
.run {
135+
// Reconstruct the base score from the vector if possible.
136+
if (score == null && vector != null) {
137+
val score = CvssVector.parseVector(vector)?.baseScore?.toFloat()
138+
copy(score = score)
139+
} else {
140+
this
141+
}
142+
}
143+
.run {
144+
// Reconstruct the severity from the scoring system and score if possible.
145+
if (severity == null && scoringSystem != null && score != null) {
146+
val severity = VulnerabilityReference.getQualitativeRating(scoringSystem, score)?.name
147+
copy(severity = severity)
148+
} else {
149+
this
150+
}
151+
}
152+
}
153+
154+
vulnerability.copy(references = normalizedReferences)
155+
}

advisor/src/test/kotlin/AdvisorTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,4 +154,5 @@ private fun mockkAdviceProvider(): AdviceProvider =
154154
private fun mockkAdvisorResult(): AdvisorResult =
155155
mockk<AdvisorResult>().apply {
156156
every { vulnerabilities } returns emptyList()
157+
every { copy(vulnerabilities = any()) } returns this
157158
}

plugins/advisors/osv/build.gradle.kts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ dependencies {
3030
implementation(projects.utils.commonUtils)
3131
implementation(projects.utils.ortUtils)
3232

33-
implementation(libs.aeSecurity)
3433
implementation(libs.kotlinx.serialization.core)
3534
implementation(libs.kotlinx.serialization.json)
3635

plugins/advisors/osv/src/funTest/assets/retrieve-package-findings-expected-result.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,25 @@
1515
"references" : [ {
1616
"url" : "https://nvd.nist.gov/vuln/detail/CVE-2020-7764",
1717
"scoring_system" : "CVSS_V3",
18-
"severity" : "MODERATE",
18+
"severity" : "MEDIUM",
1919
"score" : 5.9,
2020
"vector" : "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"
2121
}, {
2222
"url" : "https://github.com/delvedor/find-my-way/commit/ab408354690e6b9cf3c4724befb3b3fa4bb90aac",
2323
"scoring_system" : "CVSS_V3",
24-
"severity" : "MODERATE",
24+
"severity" : "MEDIUM",
2525
"score" : 5.9,
2626
"vector" : "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"
2727
}, {
2828
"url" : "https://snyk.io/vuln/SNYK-JS-FINDMYWAY-1038269",
2929
"scoring_system" : "CVSS_V3",
30-
"severity" : "MODERATE",
30+
"severity" : "MEDIUM",
3131
"score" : 5.9,
3232
"vector" : "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"
3333
}, {
3434
"url" : "https://www.npmjs.com/package/find-my-way",
3535
"scoring_system" : "CVSS_V3",
36-
"severity" : "MODERATE",
36+
"severity" : "MEDIUM",
3737
"score" : 5.9,
3838
"vector" : "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"
3939
} ]

plugins/advisors/osv/src/funTest/kotlin/OsvFunTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import io.kotest.matchers.shouldNot
2828

2929
import java.time.Instant
3030

31+
import org.ossreviewtoolkit.advisor.normalizeVulnerabilityData
3132
import org.ossreviewtoolkit.model.AdvisorResult
3233
import org.ossreviewtoolkit.model.Identifier
3334
import org.ossreviewtoolkit.model.Package
@@ -94,7 +95,7 @@ private fun createOsv(): Osv = OsvFactory.create()
9495

9596
private fun Map<Identifier, AdvisorResult>.patchTimes(): Map<Identifier, AdvisorResult> =
9697
mapValues { (_, advisorResult) ->
97-
advisorResult.copy(
98+
advisorResult.normalizeVulnerabilityData().copy(
9899
summary = advisorResult.summary.copy(
99100
startTime = Instant.EPOCH,
100101
endTime = Instant.EPOCH

plugins/advisors/osv/src/main/kotlin/Osv.kt

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ import kotlinx.serialization.json.contentOrNull
2626

2727
import org.apache.logging.log4j.kotlin.logger
2828

29-
import org.metaeffekt.core.security.cvss.CvssVector
30-
3129
import org.ossreviewtoolkit.advisor.AdviceProvider
3230
import org.ossreviewtoolkit.advisor.AdviceProviderFactory
3331
import org.ossreviewtoolkit.clients.osv.OsvServiceWrapper
@@ -167,17 +165,10 @@ private fun Vulnerability.toOrtVulnerability(): org.ossreviewtoolkit.model.vulne
167165
// Use the 'severity' property of the unspecified 'databaseSpecific' object.
168166
// See also https://github.com/google/osv.dev/issues/484.
169167
val specificSeverity = databaseSpecific?.get("severity")
170-
171-
val baseScore = runCatching {
172-
CvssVector.parseVector(vector)?.baseScore?.toFloat()
173-
}.onFailure {
174-
logger.debug { "Unable to parse CVSS vector '$vector': ${it.collectMessages()}." }
175-
}.getOrNull()
176-
177168
val severityRating = (specificSeverity as? JsonPrimitive)?.contentOrNull
178-
?: VulnerabilityReference.getQualitativeRating(scoringSystem, baseScore)?.name
179169

180-
VulnerabilityReference(it, scoringSystem, severityRating, baseScore, vector)
170+
// OSV never provides the numeric base score as it can be calculated from the vector string.
171+
VulnerabilityReference(it, scoringSystem, severityRating, null, vector)
181172
}.getOrNull()
182173
}
183174
}

plugins/advisors/vulnerable-code/src/funTest/kotlin/VulnerableCodeFunTest.kt

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import io.kotest.matchers.nulls.shouldNotBeNull
2626
import io.kotest.matchers.should
2727
import io.kotest.matchers.shouldBe
2828

29+
import org.ossreviewtoolkit.advisor.normalizeVulnerabilityData
2930
import org.ossreviewtoolkit.model.Identifier
3031
import org.ossreviewtoolkit.model.Package
3132
import org.ossreviewtoolkit.model.utils.toPurl
@@ -37,10 +38,10 @@ class VulnerableCodeFunTest : WordSpec({
3738
val id = Identifier("Go::github.com/quic-go/quic-go:0.40.0")
3839
val pkg = Package.EMPTY.copy(id, purl = id.toPurl())
3940

40-
val findings = vc.retrievePackageFindings(setOf(pkg))
41+
val results = vc.retrievePackageFindings(setOf(pkg)).values.map { it.normalizeVulnerabilityData() }
4142

42-
findings.values.flatMap { it.summary.issues } should beEmpty()
43-
with(findings.values.flatMap { it.vulnerabilities }.associateBy { it.id }) {
43+
results.flatMap { it.summary.issues } should beEmpty()
44+
with(results.flatMap { it.vulnerabilities }.associateBy { it.id }) {
4445
keys shouldContainAll setOf(
4546
"CVE-2023-49295"
4647
)
@@ -63,10 +64,10 @@ class VulnerableCodeFunTest : WordSpec({
6364
val id = Identifier("Maven:com.google.guava:guava:19.0")
6465
val pkg = Package.EMPTY.copy(id, purl = id.toPurl())
6566

66-
val findings = vc.retrievePackageFindings(setOf(pkg))
67+
val results = vc.retrievePackageFindings(setOf(pkg)).values.map { it.normalizeVulnerabilityData() }
6768

68-
findings.values.flatMap { it.summary.issues } should beEmpty()
69-
with(findings.values.flatMap { it.vulnerabilities }.associateBy { it.id }) {
69+
results.flatMap { it.summary.issues } should beEmpty()
70+
with(results.flatMap { it.vulnerabilities }.associateBy { it.id }) {
7071
keys shouldContainAll setOf(
7172
"CVE-2018-10237",
7273
"CVE-2020-8908",
@@ -89,10 +90,10 @@ class VulnerableCodeFunTest : WordSpec({
8990
val id = Identifier("Maven:org.apache.commons:commons-compress:1.23.0")
9091
val pkg = Package.EMPTY.copy(id, purl = id.toPurl())
9192

92-
val findings = vc.retrievePackageFindings(setOf(pkg))
93+
val results = vc.retrievePackageFindings(setOf(pkg)).values.map { it.normalizeVulnerabilityData() }
9394

94-
findings.values.flatMap { it.summary.issues } should beEmpty()
95-
with(findings.values.flatMap { it.vulnerabilities }.associateBy { it.id }) {
95+
results.flatMap { it.summary.issues } should beEmpty()
96+
with(results.flatMap { it.vulnerabilities }.associateBy { it.id }) {
9697
keys shouldContainAll setOf(
9798
"CVE-2023-42503"
9899
)
@@ -115,10 +116,10 @@ class VulnerableCodeFunTest : WordSpec({
115116
val id = Identifier("NPM::elliptic:6.5.7")
116117
val pkg = Package.EMPTY.copy(id, purl = id.toPurl())
117118

118-
val findings = vc.retrievePackageFindings(setOf(pkg))
119+
val results = vc.retrievePackageFindings(setOf(pkg)).values.map { it.normalizeVulnerabilityData() }
119120

120-
findings.values.flatMap { it.summary.issues } should beEmpty()
121-
with(findings.values.flatMap { it.vulnerabilities }.associateBy { it.id }) {
121+
results.flatMap { it.summary.issues } should beEmpty()
122+
with(results.flatMap { it.vulnerabilities }.associateBy { it.id }) {
122123
keys shouldContainAll setOf(
123124
"CVE-2024-48948"
124125
)

plugins/advisors/vulnerable-code/src/main/kotlin/VulnerableCode.kt

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,18 +155,14 @@ class VulnerableCode(override val descriptor: PluginDescriptor, config: Vulnerab
155155

156156
if (scores.isEmpty()) return listOf(VulnerabilityReference(sourceUri, null, null, null, null))
157157

158-
return scores.map { (scoringSystem, scoringElements, value) ->
159-
val score = value.toFloatOrNull()
160-
161-
val severity = if (score != null) {
162-
VulnerabilityReference.getQualitativeRating(scoringSystem, score)?.name
163-
} else {
164-
// VulnerableCode returns "MODERATE" instead of "MEDIUM" in case of the "cvssv3.1_qr" scoring
165-
// system, see https://github.com/aboutcode-org/vulnerablecode/issues/1186.
166-
value.takeUnless { it == "MODERATE" && scoringSystem == "cvssv3.1_qr" } ?: "MEDIUM"
167-
}
158+
return scores.map {
159+
// In VulnerableCode's data model, a Score class's value is either a numeric score or a severity string.
160+
val score = it.value.toFloatOrNull()
161+
val severity = it.value.takeUnless { score != null }
162+
163+
val vector = it.scoringElements?.ifEmpty { null }
168164

169-
VulnerabilityReference(sourceUri, scoringSystem, severity, score, scoringElements?.ifEmpty { null })
165+
VulnerabilityReference(sourceUri, it.scoringSystem, severity, score, vector)
170166
}
171167
}.onFailure {
172168
issues += createAndLogIssue(

plugins/advisors/vulnerable-code/src/test/kotlin/VulnerableCodeTest.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import io.kotest.matchers.shouldBe
4040
import java.io.File
4141
import java.net.URI
4242

43+
import org.ossreviewtoolkit.advisor.normalizeVulnerabilityData
4344
import org.ossreviewtoolkit.model.AdvisorCapability
4445
import org.ossreviewtoolkit.model.AdvisorDetails
4546
import org.ossreviewtoolkit.model.Identifier
@@ -111,7 +112,7 @@ class VulnerableCodeTest : WordSpec({
111112
)
112113
)
113114
)
114-
langResult.vulnerabilities should containExactly(expLangVulnerability)
115+
langResult.vulnerabilities.normalizeVulnerabilityData() should containExactly(expLangVulnerability)
115116

116117
val strutsResult = result.getValue(idStruts)
117118
strutsResult.advisor shouldBe vulnerableCode.details
@@ -149,7 +150,8 @@ class VulnerableCodeTest : WordSpec({
149150
)
150151
)
151152
)
152-
strutsResult.vulnerabilities should containExactlyInAnyOrder(expStrutsVulnerabilities)
153+
strutsResult.vulnerabilities.normalizeVulnerabilityData() should
154+
containExactlyInAnyOrder(expStrutsVulnerabilities)
153155
}
154156

155157
"extract the CVE ID from an alias" {
@@ -178,7 +180,8 @@ class VulnerableCodeTest : WordSpec({
178180
)
179181
)
180182
)
181-
result.getValue(idJUnit).vulnerabilities should containExactly(expJunitVulnerability)
183+
result.getValue(idJUnit).vulnerabilities.normalizeVulnerabilityData() should
184+
containExactly(expJunitVulnerability)
182185
}
183186

184187
"extract other official identifiers from aliases" {
@@ -214,7 +217,8 @@ class VulnerableCodeTest : WordSpec({
214217
)
215218
)
216219
)
217-
result.getValue(idLog4j).vulnerabilities should containExactlyInAnyOrder(expLog4jVulnerabilities)
220+
result.getValue(idLog4j).vulnerabilities.normalizeVulnerabilityData() should
221+
containExactlyInAnyOrder(expLog4jVulnerabilities)
218222
}
219223

220224
"handle a failure response from the server" {

0 commit comments

Comments
 (0)