From b87f29243f2d6ae13d74ea1d93c83aad1fe47ef2 Mon Sep 17 00:00:00 2001
From: Bo Maryniuk <bo@suse.de>
Date: Tue, 9 Oct 2018 14:08:50 +0200
Subject: [PATCH] Add CPE_NAME for osversion* grain parsing (U#49946)

Remove unnecessary linebreak

Override VERSION_ID from os-release, if CPE_NAME is given

Add unit test for WFN format of CPE_NAME

Add unit test for v2.3 of CPE format

Add unit test for broken CPE_NAME

Prevent possible crash if CPE_NAME is wrongly written in the distro

Add part parsing

Keep CPE_NAME only for opensuse series

Remove linebreak

Expand unit test to verify part name

Fix proper part name in the string-bound CPE
---
 salt/grains/core.py            | 43 +++++++++++++++++---
 tests/unit/grains/test_core.py | 73 ++++++++++++++++++++++++++++++++++
 2 files changed, 111 insertions(+), 5 deletions(-)

diff --git a/salt/grains/core.py b/salt/grains/core.py
index 5ad9efea17..301e20b952 100644
--- a/salt/grains/core.py
+++ b/salt/grains/core.py
@@ -1355,6 +1355,34 @@ def _parse_os_release():
     return data
 
 
+def _parse_cpe_name(cpe):
+    '''
+    Parse CPE_NAME data from the os-release
+
+    Info: https://csrc.nist.gov/projects/security-content-automation-protocol/scap-specifications/cpe
+
+    :param cpe:
+    :return:
+    '''
+    part = {
+        'o': 'operating system',
+        'h': 'hardware',
+        'a': 'application',
+    }
+    ret = {}
+    cpe = (cpe or '').split(':')
+    if len(cpe) > 4 and cpe[0] == 'cpe':
+        if cpe[1].startswith('/'):  # WFN to URI
+            ret['vendor'], ret['product'], ret['version'] = cpe[2:5]
+            ret['phase'] = cpe[5] if len(cpe) > 5 else None
+            ret['part'] = part.get(cpe[1][1:])
+        elif len(cpe) == 13 and cpe[1] == '2.3':  # WFN to a string
+            ret['vendor'], ret['product'], ret['version'], ret['phase'] = [x if x != '*' else None for x in cpe[3:7]]
+            ret['part'] = part.get(cpe[2])
+
+    return ret
+
+
 def os_data():
     '''
     Return grains pertaining to the operating system
@@ -1552,13 +1580,20 @@ def os_data():
                                 codename = codename_match.group(1)
                         grains['lsb_distrib_codename'] = codename
                     if 'CPE_NAME' in os_release:
-                        if ":suse:" in os_release['CPE_NAME'] or ":opensuse:" in os_release['CPE_NAME']:
+                        cpe = _parse_cpe_name(os_release['CPE_NAME'])
+                        if not cpe:
+                            log.error('Broken CPE_NAME format in /etc/os-release!')
+                        elif cpe.get('vendor', '').lower() in ['suse', 'opensuse']:
                             grains['os'] = "SUSE"
                             # openSUSE `osfullname` grain normalization
                             if os_release.get("NAME") == "openSUSE Leap":
                                 grains['osfullname'] = "Leap"
                             elif os_release.get("VERSION") == "Tumbleweed":
                                 grains['osfullname'] = os_release["VERSION"]
+                            # Override VERSION_ID, if CPE_NAME around
+                            if cpe.get('version') and cpe.get('vendor') == 'opensuse':  # Keep VERSION_ID for SLES
+                                grains['lsb_distrib_release'] = cpe['version']
+
                 elif os.path.isfile('/etc/SuSE-release'):
                     grains['lsb_distrib_id'] = 'SUSE'
                     version = ''
@@ -1664,8 +1699,7 @@ def os_data():
             # Commit introducing this comment should be reverted after the upstream bug is released.
             if 'CentOS Linux 7' in grains.get('lsb_distrib_codename', ''):
                 grains.pop('lsb_distrib_release', None)
-            grains['osrelease'] = \
-                grains.get('lsb_distrib_release', osrelease).strip()
+            grains['osrelease'] = grains.get('lsb_distrib_release', osrelease).strip()
         grains['oscodename'] = grains.get('lsb_distrib_codename', '').strip() or oscodename
         if 'Red Hat' in grains['oscodename']:
             grains['oscodename'] = oscodename
@@ -1700,8 +1734,7 @@ def os_data():
                         r'((?:Open|Oracle )?Solaris|OpenIndiana|OmniOS) (Development)?'
                         r'\s*(\d+\.?\d*|v\d+)\s?[A-Z]*\s?(r\d+|\d+\/\d+|oi_\S+|snv_\S+)?'
                     )
-                    osname, development, osmajorrelease, osminorrelease = \
-                        release_re.search(rel_data).groups()
+                    osname, development, osmajorrelease, osminorrelease = release_re.search(rel_data).groups()
                 except AttributeError:
                     # Set a blank osrelease grain and fallback to 'Solaris'
                     # as the 'os' grain.
diff --git a/tests/unit/grains/test_core.py b/tests/unit/grains/test_core.py
index b75d2fd353..3b930b0475 100644
--- a/tests/unit/grains/test_core.py
+++ b/tests/unit/grains/test_core.py
@@ -55,6 +55,79 @@ class CoreGrainsTestCase(TestCase, LoaderModuleMockMixin):
     def setup_loader_modules(self):
         return {core: {}}
 
+    @patch("os.path.isfile")
+    def test_parse_etc_os_release(self, path_isfile_mock):
+        path_isfile_mock.side_effect = lambda x: x == "/usr/lib/os-release"
+        with salt.utils.files.fopen(os.path.join(OS_RELEASE_DIR, "ubuntu-17.10")) as os_release_file:
+            os_release_content = os_release_file.read()
+        with patch("salt.utils.files.fopen", mock_open(read_data=os_release_content)):
+            os_release = core._parse_os_release(
+                '/etc/os-release',
+                '/usr/lib/os-release')
+        self.assertEqual(os_release, {
+            "NAME": "Ubuntu",
+            "VERSION": "17.10 (Artful Aardvark)",
+            "ID": "ubuntu",
+            "ID_LIKE": "debian",
+            "PRETTY_NAME": "Ubuntu 17.10",
+            "VERSION_ID": "17.10",
+            "HOME_URL": "https://www.ubuntu.com/",
+            "SUPPORT_URL": "https://help.ubuntu.com/",
+            "BUG_REPORT_URL": "https://bugs.launchpad.net/ubuntu/",
+            "PRIVACY_POLICY_URL": "https://www.ubuntu.com/legal/terms-and-policies/privacy-policy",
+            "VERSION_CODENAME": "artful",
+            "UBUNTU_CODENAME": "artful",
+        })
+
+    def test_parse_cpe_name_wfn(self):
+        '''
+        Parse correct CPE_NAME data WFN formatted
+        :return:
+        '''
+        for cpe, cpe_ret in [('cpe:/o:opensuse:leap:15.0',
+                              {'phase': None, 'version': '15.0', 'product': 'leap',
+                               'vendor': 'opensuse', 'part': 'operating system'}),
+                             ('cpe:/o:vendor:product:42:beta',
+                              {'phase': 'beta', 'version': '42', 'product': 'product',
+                               'vendor': 'vendor', 'part': 'operating system'})]:
+            ret = core._parse_cpe_name(cpe)
+            for key in cpe_ret:
+                assert key in ret
+                assert cpe_ret[key] == ret[key]
+
+    def test_parse_cpe_name_v23(self):
+        '''
+        Parse correct CPE_NAME data v2.3 formatted
+        :return:
+        '''
+        for cpe, cpe_ret in [('cpe:2.3:o:microsoft:windows_xp:5.1.601:beta:*:*:*:*:*:*',
+                              {'phase': 'beta', 'version': '5.1.601', 'product': 'windows_xp',
+                               'vendor': 'microsoft', 'part': 'operating system'}),
+                             ('cpe:2.3:h:corellian:millenium_falcon:1.0:*:*:*:*:*:*:*',
+                              {'phase': None, 'version': '1.0', 'product': 'millenium_falcon',
+                               'vendor': 'corellian', 'part': 'hardware'}),
+                             ('cpe:2.3:*:dark_empire:light_saber:3.0:beta:*:*:*:*:*:*',
+                              {'phase': 'beta', 'version': '3.0', 'product': 'light_saber',
+                               'vendor': 'dark_empire', 'part': None})]:
+            ret = core._parse_cpe_name(cpe)
+            for key in cpe_ret:
+                assert key in ret
+                assert cpe_ret[key] == ret[key]
+
+    def test_parse_cpe_name_broken(self):
+        '''
+        Parse broken CPE_NAME data
+        :return:
+        '''
+        for cpe in ['cpe:broken', 'cpe:broken:in:all:ways:*:*:*:*',
+                    'cpe:x:still:broken:123', 'who:/knows:what:is:here']:
+            assert core._parse_cpe_name(cpe) == {}
+
+    def test_missing_os_release(self):
+        with patch('salt.utils.files.fopen', mock_open(read_data={})):
+            os_release = core._parse_os_release('/etc/os-release', '/usr/lib/os-release')
+        self.assertEqual(os_release, {})
+
     @skipIf(not salt.utils.platform.is_linux(), 'System is not Linux')
     def test_gnu_slash_linux_in_os_name(self):
         '''
-- 
2.19.0


