diff --git a/vulnerabilities/api.py b/vulnerabilities/api.py
index 743a3dea2..64007c59c 100644
--- a/vulnerabilities/api.py
+++ b/vulnerabilities/api.py
@@ -38,7 +38,14 @@
class VulnerabilitySeveritySerializer(serializers.ModelSerializer):
class Meta:
model = VulnerabilitySeverity
- fields = ["value", "scoring_system", "scoring_elements"]
+ fields = ["value", "scoring_system", "scoring_elements", "published_at"]
+
+ def to_representation(self, instance):
+ data = super().to_representation(instance)
+ published_at = data.get("published_at", None)
+ if not published_at:
+ data.pop("published_at")
+ return data
class VulnerabilityReferenceSerializer(serializers.ModelSerializer):
diff --git a/vulnerabilities/import_runner.py b/vulnerabilities/import_runner.py
index 89eca49d6..d8f3f5102 100644
--- a/vulnerabilities/import_runner.py
+++ b/vulnerabilities/import_runner.py
@@ -189,6 +189,7 @@ def process_inferences(inferences: List[Inference], advisory: Advisory, improver
defaults={
"value": str(severity.value),
"scoring_elements": str(severity.scoring_elements),
+ "published_at": str(severity.published_at),
},
)
if updated:
diff --git a/vulnerabilities/importer.py b/vulnerabilities/importer.py
index 972196d65..005534512 100644
--- a/vulnerabilities/importer.py
+++ b/vulnerabilities/importer.py
@@ -52,12 +52,17 @@ class VulnerabilitySeverity:
system: ScoringSystem
value: str
scoring_elements: str = ""
+ published_at: Optional[datetime.datetime] = None
def to_dict(self):
+ published_at_dict = (
+ {"published_at": self.published_at.isoformat()} if self.published_at else {}
+ )
return {
"system": self.system.identifier,
"value": self.value,
"scoring_elements": self.scoring_elements,
+ **published_at_dict,
}
@classmethod
@@ -70,6 +75,7 @@ def from_dict(cls, severity: dict):
system=SCORING_SYSTEMS[severity["system"]],
value=severity["value"],
scoring_elements=severity.get("scoring_elements", ""),
+ published_at=severity.get("published_at"),
)
diff --git a/vulnerabilities/importers/__init__.py b/vulnerabilities/importers/__init__.py
index cedd8902b..70b9190b1 100644
--- a/vulnerabilities/importers/__init__.py
+++ b/vulnerabilities/importers/__init__.py
@@ -15,6 +15,7 @@
from vulnerabilities.importers import debian
from vulnerabilities.importers import debian_oval
from vulnerabilities.importers import elixir_security
+from vulnerabilities.importers import epss
from vulnerabilities.importers import fireeye
from vulnerabilities.importers import gentoo
from vulnerabilities.importers import github
@@ -71,6 +72,7 @@
oss_fuzz.OSSFuzzImporter,
ruby.RubyImporter,
github_osv.GithubOSVImporter,
+ epss.EPSSImporter,
]
IMPORTERS_REGISTRY = {x.qualified_name: x for x in IMPORTERS_REGISTRY}
diff --git a/vulnerabilities/importers/epss.py b/vulnerabilities/importers/epss.py
new file mode 100644
index 000000000..83822fa5d
--- /dev/null
+++ b/vulnerabilities/importers/epss.py
@@ -0,0 +1,67 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# VulnerableCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: Apache-2.0
+# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
+# See https://github.com/nexB/vulnerablecode for support or download.
+# See https://aboutcode.org for more information about nexB OSS projects.
+#
+import csv
+import gzip
+import logging
+import urllib.request
+from datetime import datetime
+from typing import Iterable
+
+from vulnerabilities import severity_systems
+from vulnerabilities.importer import AdvisoryData
+from vulnerabilities.importer import Importer
+from vulnerabilities.importer import Reference
+from vulnerabilities.importer import VulnerabilitySeverity
+
+logger = logging.getLogger(__name__)
+
+
+class EPSSImporter(Importer):
+ """Exploit Prediction Scoring System (EPSS) Importer"""
+
+ advisory_url = "https://epss.cyentia.com/epss_scores-current.csv.gz"
+ spdx_license_expression = "unknown"
+ importer_name = "EPSS Importer"
+
+ def advisory_data(self) -> Iterable[AdvisoryData]:
+ response = urllib.request.urlopen(self.advisory_url)
+ with gzip.open(response, "rb") as f:
+ lines = [l.decode("utf-8") for l in f.readlines()]
+
+ epss_reader = csv.reader(lines)
+ model_version, score_date = next(
+ epss_reader
+ ) # score_date='score_date:2024-05-19T00:00:00+0000'
+ published_at = datetime.strptime(score_date[11::], "%Y-%m-%dT%H:%M:%S%z")
+
+ next(epss_reader) # skip the header row
+ for epss_row in epss_reader:
+ cve, score, percentile = epss_row
+
+ if not cve or not score or not percentile:
+ logger.error(f"Invalid epss row: {epss_row}")
+ continue
+
+ severity = VulnerabilitySeverity(
+ system=severity_systems.EPSS,
+ value=score,
+ scoring_elements=percentile,
+ published_at=published_at,
+ )
+
+ references = Reference(
+ url=f"https://api.first.org/data/v1/epss?cve={cve}",
+ severities=[severity],
+ )
+
+ yield AdvisoryData(
+ aliases=[cve],
+ references=[references],
+ url=self.advisory_url,
+ )
diff --git a/vulnerabilities/migrations/0059_vulnerabilityseverity_published_at_and_more.py b/vulnerabilities/migrations/0059_vulnerabilityseverity_published_at_and_more.py
new file mode 100644
index 000000000..a737b9c9f
--- /dev/null
+++ b/vulnerabilities/migrations/0059_vulnerabilityseverity_published_at_and_more.py
@@ -0,0 +1,43 @@
+# Generated by Django 4.1.13 on 2024-08-06 09:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("vulnerabilities", "0058_alter_vulnerabilityreference_options_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="vulnerabilityseverity",
+ name="published_at",
+ field=models.DateTimeField(
+ blank=True,
+ help_text="UTC Date of publication of the vulnerability severity",
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="vulnerabilityseverity",
+ name="scoring_system",
+ field=models.CharField(
+ choices=[
+ ("cvssv2", "CVSSv2 Base Score"),
+ ("cvssv3", "CVSSv3 Base Score"),
+ ("cvssv3.1", "CVSSv3.1 Base Score"),
+ ("rhbs", "RedHat Bugzilla severity"),
+ ("rhas", "RedHat Aggregate severity"),
+ ("archlinux", "Archlinux Vulnerability Group Severity"),
+ ("cvssv3.1_qr", "CVSSv3.1 Qualitative Severity Rating"),
+ ("generic_textual", "Generic textual severity rating"),
+ ("apache_httpd", "Apache Httpd Severity"),
+ ("apache_tomcat", "Apache Tomcat Severity"),
+ ("epss", "Exploit Prediction Scoring System"),
+ ],
+ help_text="Identifier for the scoring system used. Available choices are: cvssv2: CVSSv2 Base Score,\ncvssv3: CVSSv3 Base Score,\ncvssv3.1: CVSSv3.1 Base Score,\nrhbs: RedHat Bugzilla severity,\nrhas: RedHat Aggregate severity,\narchlinux: Archlinux Vulnerability Group Severity,\ncvssv3.1_qr: CVSSv3.1 Qualitative Severity Rating,\ngeneric_textual: Generic textual severity rating,\napache_httpd: Apache Httpd Severity,\napache_tomcat: Apache Tomcat Severity,\nepss: Exploit Prediction Scoring System ",
+ max_length=50,
+ ),
+ ),
+ ]
diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py
index f09794565..4e7939c30 100644
--- a/vulnerabilities/models.py
+++ b/vulnerabilities/models.py
@@ -936,6 +936,10 @@ class VulnerabilitySeverity(models.Model):
"For example a CVSS vector string as used to compute a CVSS score.",
)
+ published_at = models.DateTimeField(
+ blank=True, null=True, help_text="UTC Date of publication of the vulnerability severity"
+ )
+
class Meta:
unique_together = ["reference", "scoring_system", "value"]
ordering = ["reference", "scoring_system", "value"]
diff --git a/vulnerabilities/severity_systems.py b/vulnerabilities/severity_systems.py
index 6260750b2..bc8d6219d 100644
--- a/vulnerabilities/severity_systems.py
+++ b/vulnerabilities/severity_systems.py
@@ -157,6 +157,19 @@ def get(self, scoring_elements: str) -> dict:
"Low",
]
+
+@dataclasses.dataclass(order=True)
+class EPSSScoringSystem(ScoringSystem):
+ def compute(self, scoring_elements: str):
+ return NotImplementedError
+
+
+EPSS = EPSSScoringSystem(
+ identifier="epss",
+ name="Exploit Prediction Scoring System",
+ url="https://www.first.org/epss/",
+)
+
SCORING_SYSTEMS = {
system.identifier: system
for system in (
@@ -170,5 +183,6 @@ def get(self, scoring_elements: str) -> dict:
GENERIC,
APACHE_HTTPD,
APACHE_TOMCAT,
+ EPSS,
)
}
diff --git a/vulnerabilities/templates/vulnerability_details.html b/vulnerabilities/templates/vulnerability_details.html
index f26cb61fc..4ddbad9cd 100644
--- a/vulnerabilities/templates/vulnerability_details.html
+++ b/vulnerabilities/templates/vulnerability_details.html
@@ -60,13 +60,25 @@
- {% if vulnerability.kev %}
+
+ {% if vulnerability.kev %}
+
Known Exploited Vulnerabilities
- {% endif %}
+
+ {% endif %}
+
+
+
+
+ EPSS
+
+
+
+
@@ -390,87 +402,141 @@
{% endfor %}
{% if vulnerability.kev %}
-
-
- Known Exploited Vulnerabilities
-
-
-
-
-
-
- Known Ransomware Campaign Use:
-
- |
- {{ vulnerability.kev.get_known_ransomware_campaign_use_type }} |
-
-
- {% if vulnerability.kev.description %}
-
-
-
- Description:
-
- |
- {{ vulnerability.kev.description }} |
-
- {% endif %}
- {% if vulnerability.kev.required_action %}
+
+
+ Known Exploited Vulnerabilities
+
+
+
- Required Action:
+ data-tooltip="'Known' if this vulnerability is known to have been leveraged as part of a ransomware campaign; 'Unknown' if CISA lacks confirmation that the vulnerability has been utilized for ransomware">
+ Known Ransomware Campaign Use:
|
- {{ vulnerability.kev.required_action }} |
+ {{ vulnerability.kev.get_known_ransomware_campaign_use_type }} |
- {% endif %}
-
- {% if vulnerability.kev.resources_and_notes %}
+
+ {% if vulnerability.kev.description %}
+
+
+
+ Description:
+
+ |
+ {{ vulnerability.kev.description }} |
+
+ {% endif %}
+ {% if vulnerability.kev.required_action %}
+
+
+
+ Required Action:
+
+ |
+ {{ vulnerability.kev.required_action }} |
+
+ {% endif %}
+
+ {% if vulnerability.kev.resources_and_notes %}
+
+
+
+ Notes:
+
+ |
+ {{ vulnerability.kev.resources_and_notes }} |
+
+ {% endif %}
+
+ {% if vulnerability.kev.due_date %}
+
+
+
+ Due Date:
+
+ |
+ {{ vulnerability.kev.due_date }} |
+
+ {% endif %}
+ {% if vulnerability.kev.date_added %}
+
+
+
+ Date Added:
+
+ |
+ {{ vulnerability.kev.date_added }} |
+
+ {% endif %}
+
+
+
+
+ {% endif %}
+
+ {% for severity in severities %}
+ {% if severity.scoring_system == 'epss' %}
+
+
+ Exploit Prediction Scoring System
+
+
+
- Notes:
+ data-tooltip="the percentile of the current score, the proportion of all scored vulnerabilities with the same or a lower EPSS score">
+ Percentile:
|
- {{ vulnerability.kev.resources_and_notes }} |
+ {{ severity.scoring_elements }} |
- {% endif %}
-
- {% if vulnerability.kev.due_date %}
+
- Due Date:
+ data-tooltip="the EPSS score representing the probability [0-1] of exploitation in the wild in the next 30 days (following score publication)">
+ EPSS score:
|
- {{ vulnerability.kev.due_date }} |
+ {{ severity.value }} |
- {% endif %}
- {% if vulnerability.kev.date_added %}
+
+ {% if severity.published_at %}
- Date Added:
+ data-tooltip="When was the time we fetched epss">
+ Published at:
|
- {{ vulnerability.kev.date_added }} |
+ {{ severity.published_at }} |
- {% endif %}
-
-
+ {% endif %}
+
+
+
{% endif %}
-
-
+ {% empty %}
+
+
+
+ There are no EPSS available.
+ |
+
+
+ {% endfor %}
diff --git a/vulnerabilities/tests/test_api.py b/vulnerabilities/tests/test_api.py
index 5194d64db..93401855d 100644
--- a/vulnerabilities/tests/test_api.py
+++ b/vulnerabilities/tests/test_api.py
@@ -29,7 +29,9 @@
from vulnerabilities.models import Vulnerability
from vulnerabilities.models import VulnerabilityReference
from vulnerabilities.models import VulnerabilityRelatedReference
+from vulnerabilities.models import VulnerabilitySeverity
from vulnerabilities.models import Weakness
+from vulnerabilities.severity_systems import EPSS
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
TEST_DATA = os.path.join(BASE_DIR, "test_data")
@@ -213,6 +215,23 @@ def setUp(self):
PackageRelatedVulnerability.objects.create(
package=pkg, vulnerability=self.vulnerability, fix=True
)
+
+ self.reference1 = VulnerabilityReference.objects.create(
+ reference_id="",
+ url="https://.com",
+ )
+
+ VulnerabilitySeverity.objects.create(
+ reference=self.reference1,
+ scoring_system=EPSS.identifier,
+ scoring_elements=".0016",
+ value="0.526",
+ )
+
+ VulnerabilityRelatedReference.objects.create(
+ reference=self.reference1, vulnerability=self.vulnerability
+ )
+
self.weaknesses = Weakness.objects.create(cwe_id=119)
self.weaknesses.vulnerabilities.add(self.vulnerability)
self.invalid_weaknesses = Weakness.objects.create(
@@ -256,7 +275,21 @@ def test_api_with_single_vulnerability(self):
},
],
"affected_packages": [],
- "references": [],
+ "references": [
+ {
+ "reference_url": "https://.com",
+ "reference_id": "",
+ "reference_type": "",
+ "scores": [
+ {
+ "value": "0.526",
+ "scoring_system": "epss",
+ "scoring_elements": ".0016",
+ }
+ ],
+ "url": "https://.com",
+ }
+ ],
"weaknesses": [
{
"cwe_id": 119,
@@ -286,7 +319,21 @@ def test_api_with_single_vulnerability_with_filters(self):
},
],
"affected_packages": [],
- "references": [],
+ "references": [
+ {
+ "reference_url": "https://.com",
+ "reference_id": "",
+ "reference_type": "",
+ "scores": [
+ {
+ "value": "0.526",
+ "scoring_system": "epss",
+ "scoring_elements": ".0016",
+ }
+ ],
+ "url": "https://.com",
+ }
+ ],
"weaknesses": [
{
"cwe_id": 119,
diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py
index 391c165e7..68ce09faf 100644
--- a/vulnerabilities/views.py
+++ b/vulnerabilities/views.py
@@ -28,6 +28,7 @@
from vulnerabilities.forms import PackageSearchForm
from vulnerabilities.forms import VulnerabilitySearchForm
from vulnerabilities.models import VulnerabilityStatusType
+from vulnerabilities.severity_systems import EPSS
from vulnerabilities.severity_systems import SCORING_SYSTEMS
from vulnerabilities.utils import get_severity_range
from vulnerablecode.settings import env
@@ -137,7 +138,11 @@ def get_context_data(self, **kwargs):
status = self.object.get_status_label
severity_vectors = []
+ severity_values = set()
for s in self.object.severities:
+ if s.scoring_system == EPSS.identifier:
+ continue
+
if s.scoring_elements and s.scoring_system in SCORING_SYSTEMS:
try:
vector_values = SCORING_SYSTEMS[s.scoring_system].get(s.scoring_elements)
@@ -145,14 +150,15 @@ def get_context_data(self, **kwargs):
except (CVSS2MalformedError, CVSS3MalformedError, NotImplementedError):
logging.error(f"CVSSMalformedError for {s.scoring_elements}")
+ if s.value:
+ severity_values.add(s.value)
+
context.update(
{
"vulnerability": self.object,
"vulnerability_search_form": VulnerabilitySearchForm(self.request.GET),
"severities": list(self.object.severities),
- "severity_score_range": get_severity_range(
- {s.value for s in self.object.severities}
- ),
+ "severity_score_range": get_severity_range(severity_values),
"severity_vectors": severity_vectors,
"references": self.object.references.all(),
"aliases": self.object.aliases.all(),