From 5c11d494bd0390085092d79b8c58c43f8a947e2a Mon Sep 17 00:00:00 2001 From: Jan Kaluza Date: Apr 12 2018 17:48:09 +0000 Subject: Add SecurityDataAPI class to get CVE severity and use it in ErrataAdvisory. --- diff --git a/freshmaker/config.py b/freshmaker/config.py index de2d6c8..c470973 100644 --- a/freshmaker/config.py +++ b/freshmaker/config.py @@ -199,6 +199,10 @@ class Config(object): 'desc': 'Dict with base container "name-version" as key and URL ' 'to extra .repo file to include in a rebuild', }, + 'security_data_server_url': { + 'type': str, + 'default': 'https://access.redhat.com/labs/securitydataapi', + 'desc': 'Server URL of SecurityDataAPI.'}, 'lightblue_server_url': { 'type': str, 'default': '', diff --git a/freshmaker/errata.py b/freshmaker/errata.py index c70d2eb..ab77a9a 100644 --- a/freshmaker/errata.py +++ b/freshmaker/errata.py @@ -29,6 +29,7 @@ from freshmaker.events import ( BrewSignRPMEvent, ErrataBaseEvent, FreshmakerManualRebuildEvent) from freshmaker import conf, log +from freshmaker.security_data import SecurityDataAPI class ErrataAdvisory(object): @@ -49,6 +50,11 @@ class ErrataAdvisory(object): self.security_impact = security_impact or "" self.product_short_name = product_short_name or "" self.cve_list = cve_list or [] + self.highest_cve_severity = None + + sec_data = SecurityDataAPI() + self.highest_cve_severity = sec_data.get_highest_threat_severity( + self.cve_list) @classmethod def from_advisory_id(cls, errata, errata_id): diff --git a/freshmaker/security_data.py b/freshmaker/security_data.py new file mode 100644 index 0000000..2600483 --- /dev/null +++ b/freshmaker/security_data.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018 Red Hat, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# Written by Jan Kaluza + +import requests + +from freshmaker import log, conf + + +class SecurityDataAPI(object): + + # Ordered Threat severities. + THREAT_SEVERITIES = [ + "low", + "moderate", + "important", + "critical", + ] + + def __init__(self, server_url=None): + """ + Creates new SecurityDataAPI instance. + + :param str server_url: SecurityDataAPI base URL. + """ + if server_url is not None: + self.server_url = server_url.rstrip('/') + else: + self.server_url = conf.security_data_server_url.rstrip('/') + + def _get_cve(self, cve): + """ + Returns the JSON with metadata about `cve` obtained from + /cve/$cve.json endpoint. + + :param str cve: CVE, for example "CVE-2017-10268". + :rtype: dict + :return: Dict with metadata about CVE. + """ + log.debug("Querying SecurityDataAPI for %s", cve) + r = requests.get("%s/cve/%s.json" % (self.server_url, cve)) + r.raise_for_status() + return r.json() + + def get_highest_threat_severity(self, cve_list): + """ + Fetches metadata about each CVE in `cve_list` and returns the name of + highest severity rate. See `SecurityDataAPI.THREAT_SEVERITIES` for + list of possible severity rates. + + :param list cve_list: List of strings with CVE names. + :rtype: str + :return: Name of highest severity rate occuring in CVEs from `cve_list`. + """ + max_rating = -1 + for cve in cve_list: + data = self._get_cve(cve) + severity = data["threat_severity"].lower() + try: + rating = SecurityDataAPI.THREAT_SEVERITIES.index(severity) + except ValueError: + log.error("Unknown threat_severity '%s' for CVE %s", + severity, cve) + continue + max_rating = max(max_rating, rating) + + if max_rating == -1: + return None + return SecurityDataAPI.THREAT_SEVERITIES[max_rating] diff --git a/tests/test_errata.py b/tests/test_errata.py index fc60ef8..48871fd 100644 --- a/tests/test_errata.py +++ b/tests/test_errata.py @@ -142,6 +142,15 @@ class TestErrata(helpers.FreshmakerTestCase): super(TestErrata, self).setUp() self.errata = Errata("https://localhost/") + self.patcher = helpers.Patcher( + 'freshmaker.errata.SecurityDataAPI.') + self.patcher.patch("get_highest_threat_severity", + return_value="moderate") + + def tearDown(self): + super(TestErrata, self).tearDown() + self.patcher.unpatch_all() + @patch.object(Errata, "_errata_rest_get") @patch.object(Errata, "_errata_http_get") def test_advisories_from_event(self, errata_http_get, errata_rest_get): @@ -157,6 +166,7 @@ class TestErrata(helpers.FreshmakerTestCase): self.assertEqual(advisories[0].product_short_name, "product") self.assertEqual(advisories[0].cve_list, ["CVE-2015-3253", "CVE-2016-6814"]) + self.assertEqual(advisories[0].highest_cve_severity, "moderate") @patch.object(Errata, "_errata_rest_get") @patch.object(Errata, "_errata_http_get") diff --git a/tests/test_security_data.py b/tests/test_security_data.py new file mode 100644 index 0000000..f30660d --- /dev/null +++ b/tests/test_security_data.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017 Red Hat, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from mock import patch + +from freshmaker.security_data import SecurityDataAPI +from tests import helpers + + +class TestSecurityDataAPI(helpers.FreshmakerTestCase): + + @patch("freshmaker.security_data.requests.get") + def test_get_highest_threat_severity(self, requests_get): + severities = ["Low", "Moderate", "Important", "Critical"] + sec_data = SecurityDataAPI() + for num_of_cves in range(1, 4): + requests_get.return_value.json.side_effect = [ + {"threat_severity": severity} for severity in severities] + ret = sec_data.get_highest_threat_severity(["CVE-1"] * num_of_cves) + self.assertEqual(ret, severities[num_of_cves - 1].lower()) + + @patch("freshmaker.security_data.requests.get") + def test_get_highest_threat_severity_empty_list(self, requests_get): + sec_data = SecurityDataAPI() + ret = sec_data.get_highest_threat_severity([]) + self.assertEqual(ret, None) + requests_get.assert_not_called() + + @patch("freshmaker.security_data.requests.get") + def test_get_highest_threat_severity_unknown_severity(self, requests_get): + severities = ["Low", "unknown"] + requests_get.return_value.json.side_effect = [ + {"threat_severity": severity} for severity in severities] + sec_data = SecurityDataAPI() + ret = sec_data.get_highest_threat_severity(["CVE-1", "CVE-2"]) + self.assertEqual(ret, "low")