Skip to content

Commit 9c23eb8

Browse files
authored
Merge pull request #1646 from ziadhany/save-risk
Add support for storing exploitability and weighted severity
2 parents d029151 + 1a9df9b commit 9c23eb8

File tree

10 files changed

+257
-42
lines changed

10 files changed

+257
-42
lines changed

vulnerabilities/api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,9 @@ class Meta:
286286
"weaknesses",
287287
"exploits",
288288
"severity_range_score",
289+
"exploitability",
290+
"weighted_severity",
291+
"risk_score",
289292
]
290293

291294

vulnerabilities/api_v2.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class VulnerabilityV2Serializer(serializers.ModelSerializer):
6767
weaknesses = WeaknessV2Serializer(many=True)
6868
references = VulnerabilityReferenceV2Serializer(many=True, source="vulnerabilityreference_set")
6969
severities = VulnerabilitySeverityV2Serializer(many=True)
70+
exploitability = serializers.FloatField(read_only=True)
71+
weighted_severity = serializers.FloatField(read_only=True)
72+
risk_score = serializers.FloatField(read_only=True)
7073

7174
class Meta:
7275
model = Vulnerability
@@ -77,6 +80,9 @@ class Meta:
7780
"severities",
7881
"weaknesses",
7982
"references",
83+
"exploitability",
84+
"weighted_severity",
85+
"risk_score",
8086
]
8187

8288
def get_aliases(self, obj):
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 4.2.16 on 2024-11-17 13:52
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("vulnerabilities", "0081_alter_packagechangelog_software_version_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="vulnerability",
15+
name="exploitability",
16+
field=models.DecimalField(
17+
decimal_places=1,
18+
help_text="Exploitability indicates the likelihood that a vulnerability in a software package could be used by malicious actors to compromise systems, applications, or networks. This metric is determined automatically based on the discovery of known exploits.",
19+
max_digits=2,
20+
null=True,
21+
),
22+
),
23+
migrations.AddField(
24+
model_name="vulnerability",
25+
name="weighted_severity",
26+
field=models.DecimalField(
27+
decimal_places=1,
28+
help_text="Weighted severity is the highest value calculated by multiplying each severity by its corresponding weight, divided by 10.",
29+
max_digits=3,
30+
null=True,
31+
),
32+
),
33+
migrations.AlterField(
34+
model_name="package",
35+
name="risk_score",
36+
field=models.DecimalField(
37+
decimal_places=1,
38+
help_text="Risk score between 0.00 and 10.00, where higher values indicate greater vulnerability risk for the package.",
39+
max_digits=3,
40+
null=True,
41+
),
42+
),
43+
]

vulnerabilities/models.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,33 @@ class Vulnerability(models.Model):
243243
related_name="vulnerabilities",
244244
)
245245

246+
exploitability = models.DecimalField(
247+
null=True,
248+
max_digits=2,
249+
decimal_places=1,
250+
help_text="Exploitability indicates the likelihood that a vulnerability in a software package could be used by malicious actors to compromise systems, "
251+
"applications, or networks. This metric is determined automatically based on the discovery of known exploits.",
252+
)
253+
254+
weighted_severity = models.DecimalField(
255+
null=True,
256+
max_digits=3,
257+
decimal_places=1,
258+
help_text="Weighted severity is the highest value calculated by multiplying each severity by its corresponding weight, divided by 10.",
259+
)
260+
261+
@property
262+
def risk_score(self):
263+
"""
264+
Risk expressed as a number ranging from 0 to 10.
265+
Risk is calculated from weighted severity and exploitability values.
266+
It is the maximum value of (the weighted severity multiplied by its exploitability) or 10
267+
Risk = min(weighted severity * exploitability, 10)
268+
"""
269+
if self.exploitability and self.weighted_severity:
270+
risk_score = min(float(self.exploitability * self.weighted_severity), 10.0)
271+
return round(risk_score, 1)
272+
246273
objects = VulnerabilityQuerySet.as_manager()
247274

248275
class Meta:
@@ -672,8 +699,8 @@ class Package(PackageURLMixin):
672699

673700
risk_score = models.DecimalField(
674701
null=True,
675-
max_digits=4,
676-
decimal_places=2,
702+
max_digits=3,
703+
decimal_places=1,
677704
help_text="Risk score between 0.00 and 10.00, where higher values "
678705
"indicate greater vulnerability risk for the package.",
679706
)

vulnerabilities/pipelines/compute_package_risk.py

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
77
# See https://aboutcode.org for more information about nexB OSS projects.
88
#
9-
109
from aboutcode.pipeline import LoopProgress
10+
from django.db.models import Prefetch
1111

1212
from vulnerabilities.models import Package
13+
from vulnerabilities.models import Vulnerability
1314
from vulnerabilities.pipelines import VulnerableCodePipeline
1415
from vulnerabilities.risk import compute_package_risk
16+
from vulnerabilities.risk import compute_vulnerability_risk_factors
1517

1618

1719
class ComputePackageRiskPipeline(VulnerableCodePipeline):
@@ -26,15 +28,73 @@ class ComputePackageRiskPipeline(VulnerableCodePipeline):
2628

2729
@classmethod
2830
def steps(cls):
29-
return (cls.add_package_risk_score,)
31+
return (
32+
cls.compute_and_store_vulnerability_risk_score,
33+
cls.compute_and_store_package_risk_score,
34+
)
35+
36+
def compute_and_store_vulnerability_risk_score(self):
37+
affected_vulnerabilities = (
38+
Vulnerability.objects.filter(affecting_packages__isnull=False)
39+
.prefetch_related(
40+
"references",
41+
"severities",
42+
"exploits",
43+
)
44+
.distinct()
45+
)
46+
47+
self.log(
48+
f"Calculating risk for {affected_vulnerabilities.count():,d} vulnerability with a affected packages records"
49+
)
50+
51+
progress = LoopProgress(total_iterations=affected_vulnerabilities.count(), logger=self.log)
52+
53+
updatables = []
54+
updated_vulnerability_count = 0
55+
batch_size = 5000
56+
57+
for vulnerability in progress.iter(affected_vulnerabilities.paginated(per_page=batch_size)):
58+
severities = vulnerability.severities.all()
59+
references = vulnerability.references.all()
60+
exploits = vulnerability.exploits.all()
61+
62+
weighted_severity, exploitability = compute_vulnerability_risk_factors(
63+
references=references,
64+
severities=severities,
65+
exploits=exploits,
66+
)
67+
vulnerability.weighted_severity = weighted_severity
68+
vulnerability.exploitability = exploitability
69+
70+
updatables.append(vulnerability)
71+
72+
if len(updatables) >= batch_size:
73+
updated_vulnerability_count += bulk_update(
74+
model=Vulnerability,
75+
items=updatables,
76+
fields=["weighted_severity", "exploitability"],
77+
logger=self.log,
78+
)
79+
80+
updated_vulnerability_count += bulk_update(
81+
model=Vulnerability,
82+
items=updatables,
83+
fields=["weighted_severity", "exploitability"],
84+
logger=self.log,
85+
)
86+
87+
self.log(
88+
f"Successfully added risk score for {updated_vulnerability_count:,d} vulnerability"
89+
)
3090

31-
def add_package_risk_score(self):
91+
def compute_and_store_package_risk_score(self):
3292
affected_packages = (
3393
Package.objects.filter(affected_by_vulnerabilities__isnull=False).prefetch_related(
34-
"affectedbypackagerelatedvulnerability_set__vulnerability",
35-
"affectedbypackagerelatedvulnerability_set__vulnerability__references",
36-
"affectedbypackagerelatedvulnerability_set__vulnerability__severities",
37-
"affectedbypackagerelatedvulnerability_set__vulnerability__exploits",
94+
Prefetch(
95+
"affectedbypackagerelatedvulnerability_set__vulnerability",
96+
queryset=Vulnerability.objects.only("weighted_severity", "exploitability"),
97+
),
3898
)
3999
).distinct()
40100

@@ -60,24 +120,28 @@ def add_package_risk_score(self):
60120
updatables.append(package)
61121

62122
if len(updatables) >= batch_size:
63-
updated_package_count += bulk_update_package_risk_score(
64-
packages=updatables,
123+
updated_package_count += bulk_update(
124+
model=Package,
125+
items=updatables,
126+
fields=["risk_score"],
65127
logger=self.log,
66128
)
67-
updated_package_count += bulk_update_package_risk_score(
68-
packages=updatables,
129+
updated_package_count += bulk_update(
130+
model=Package,
131+
items=updatables,
132+
fields=["risk_score"],
69133
logger=self.log,
70134
)
71135
self.log(f"Successfully added risk score for {updated_package_count:,d} package")
72136

73137

74-
def bulk_update_package_risk_score(packages, logger):
75-
package_count = 0
76-
if packages:
138+
def bulk_update(model, items, fields, logger):
139+
item_count = 0
140+
if items:
77141
try:
78-
Package.objects.bulk_update(objs=packages, fields=["risk_score"])
79-
package_count += len(packages)
142+
model.objects.bulk_update(objs=items, fields=fields)
143+
item_count += len(items)
80144
except Exception as e:
81-
logger(f"Error updating packages: {e}")
82-
packages.clear()
83-
return package_count
145+
logger(f"Error updating {model.__name__}: {e}")
146+
items.clear()
147+
return item_count

vulnerabilities/risk.py

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
77
# See https://aboutcode.org for more information about nexB OSS projects.
88
#
9-
10-
119
from urllib.parse import urlparse
1210

1311
from vulnerabilities.models import VulnerabilityReference
@@ -23,6 +21,8 @@ def get_weighted_severity(severities):
2321
by its associated Weight/10.
2422
Example of Weighted Severity: max(7*(10/10), 8*(3/10), 6*(8/10)) = 7
2523
"""
24+
if not severities:
25+
return 0
2626

2727
score_map = {
2828
"low": 3,
@@ -49,7 +49,9 @@ def get_weighted_severity(severities):
4949
vul_score_value = score_map.get(vul_score, 0) * max_weight
5050

5151
score_list.append(vul_score_value)
52-
return max(score_list) if score_list else 0
52+
53+
max_score = max(score_list) if score_list else 0
54+
return round(max_score, 1)
5355

5456

5557
def get_exploitability_level(exploits, references, severities):
@@ -83,35 +85,30 @@ def get_exploitability_level(exploits, references, severities):
8385
return exploit_level
8486

8587

86-
def compute_vulnerability_risk(vulnerability):
88+
def compute_vulnerability_risk_factors(references, severities, exploits):
8789
"""
8890
Risk may be expressed as a number ranging from 0 to 10.
8991
Risk is calculated from weighted severity and exploitability values.
9092
It is the maximum value of (the weighted severity multiplied by its exploitability) or 10
9193
9294
Risk = min(weighted severity * exploitability, 10)
9395
"""
94-
severities = vulnerability.severities.all()
95-
exploits = vulnerability.exploits.all()
96-
reference = vulnerability.references.all()
97-
if reference.exists() or severities.exists() or exploits.exists():
98-
weighted_severity = get_weighted_severity(severities)
99-
exploitability = get_exploitability_level(exploits, reference, severities)
100-
return min(weighted_severity * exploitability, 10)
96+
weighted_severity = get_weighted_severity(severities)
97+
exploitability = get_exploitability_level(exploits, references, severities)
98+
return weighted_severity, exploitability
10199

102100

103101
def compute_package_risk(package):
104102
"""
105103
Calculate the risk for a package by iterating over all vulnerabilities that affects this package
106104
and determining the associated risk.
107105
"""
108-
109106
result = []
110-
for package_vulnerability in package.affectedbypackagerelatedvulnerability_set.all():
111-
if risk := compute_vulnerability_risk(package_vulnerability.vulnerability):
112-
result.append(risk)
107+
for relation in package.affectedbypackagerelatedvulnerability_set.all():
108+
if risk := relation.vulnerability.risk_score:
109+
result.append(float(risk))
113110

114111
if not result:
115112
return
116113

117-
return f"{max(result):.2f}"
114+
return round(max(result), 1)

vulnerabilities/templates/vulnerability_details.html

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,38 @@
121121
<td class="two-col-left">Status</td>
122122
<td class="two-col-right">{{ status }}</td>
123123
</tr>
124+
125+
<tr>
126+
<td class="two-col-left"
127+
data-tooltip="Exploitability indicates the likelihood that a vulnerability in a software package
128+
could be used by malicious actors to compromise systems,
129+
applications, or networks. This metric is determined automatically based on the discovery of known exploits.">
130+
Exploitability</td>
131+
<td class="two-col-right wrap-strings">
132+
{{ vulnerability.exploitability }}
133+
</td>
134+
</tr>
135+
136+
<tr>
137+
<td class="two-col-left"
138+
data-tooltip="Weighted severity is the highest value calculated by multiplying each severity by its corresponding weight, divided by 10."
139+
>Weighted Severity</td>
140+
<td class="two-col-right wrap-strings">
141+
{{ vulnerability.weighted_severity }}
142+
</td>
143+
</tr>
144+
145+
<tr>
146+
<td class="two-col-left"
147+
data-tooltip="Risk expressed as a number ranging from 0 to 10. It is calculated by multiplying
148+
the weighted severity and exploitability values, capped at a maximum of 10.
149+
"
150+
>Risk</td>
151+
<td class="two-col-right wrap-strings">
152+
{{ vulnerability.risk_score }}
153+
</td>
154+
</tr>
155+
124156
</tbody>
125157
</table>
126158
</div>

vulnerabilities/tests/pipelines/test_compute_package_risk.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
77
# See https://aboutcode.org for more information about nexB OSS projects.
88
#
9+
from decimal import Decimal
910

1011
import pytest
1112

@@ -30,4 +31,4 @@ def test_simple_risk_pipeline(vulnerability):
3031
improver.execute()
3132

3233
pkg = Package.objects.get(type="pypi", name="foo", version="2.3.0")
33-
assert str(pkg.risk_score) == str(3.11)
34+
assert pkg.risk_score == Decimal("3.1") # max( 6.9 * 9/10 , 6.5 * 9/10 ) * .5 = 3.105

0 commit comments

Comments
 (0)