From 8d37933334f56766394c215c08110c51abf10b5f Mon Sep 17 00:00:00 2001
From: Mihai Dinca <mdinca@suse.de>
Date: Fri, 10 Feb 2017 09:22:17 +0100
Subject: [PATCH 49/49] OpenSCAP module

Include oscap returncode in response

Always return oscap's stderr
---
 salt/modules/openscap.py            | 109 +++++++++++++++++++
 tests/unit/modules/openscap_test.py | 205 ++++++++++++++++++++++++++++++++++++
 2 files changed, 314 insertions(+)
 create mode 100644 salt/modules/openscap.py
 create mode 100644 tests/unit/modules/openscap_test.py

diff --git a/salt/modules/openscap.py b/salt/modules/openscap.py
new file mode 100644
index 0000000000..2061550012
--- /dev/null
+++ b/salt/modules/openscap.py
@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+import tempfile
+import shlex
+import shutil
+from subprocess import Popen, PIPE
+
+from salt.client import Caller
+
+
+ArgumentParser = object
+
+try:
+    import argparse  # pylint: disable=minimum-python-version
+    ArgumentParser = argparse.ArgumentParser
+    HAS_ARGPARSE = True
+except ImportError:  # python 2.6
+    HAS_ARGPARSE = False
+
+
+_XCCDF_MAP = {
+    'eval': {
+        'parser_arguments': [
+            (('--profile',), {'required': True}),
+        ],
+        'cmd_pattern': (
+            "oscap xccdf eval "
+            "--oval-results --results results.xml --report report.html "
+            "--profile {0} {1}"
+        )
+    }
+}
+
+
+def __virtual__():
+    return HAS_ARGPARSE, 'argparse module is required.'
+
+
+class _ArgumentParser(ArgumentParser):
+
+    def __init__(self, action=None, *args, **kwargs):
+        super(_ArgumentParser, self).__init__(*args, prog='oscap', **kwargs)
+        self.add_argument('action', choices=['eval'])
+        add_arg = None
+        for params, kwparams in _XCCDF_MAP['eval']['parser_arguments']:
+            self.add_argument(*params, **kwparams)
+
+    def error(self, message, *args, **kwargs):
+        raise Exception(message)
+
+
+_OSCAP_EXIT_CODES_MAP = {
+    0: True,  # all rules pass
+    1: False,  # there is an error during evaluation
+    2: True  # there is at least one rule with either fail or unknown result
+}
+
+
+def xccdf(params):
+    '''
+    Run ``oscap xccdf`` commands on minions.
+    It uses cp.push_dir to upload the generated files to the salt master
+    in the master's minion files cachedir
+    (defaults to ``/var/cache/salt/master/minions/minion-id/files``)
+
+    It needs ``file_recv`` set to ``True`` in the master configuration file.
+
+    CLI Example:
+
+    .. code-block:: bash
+
+        salt '*'  openscap.xccdf "eval --profile Default /usr/share/openscap/scap-yast2sec-xccdf.xml"
+    '''
+    params = shlex.split(params)
+    policy = params[-1]
+
+    success = True
+    error = None
+    upload_dir = None
+    action = None
+    returncode = None
+
+    try:
+        parser = _ArgumentParser()
+        action = parser.parse_known_args(params)[0].action
+        args, argv = _ArgumentParser(action=action).parse_known_args(args=params)
+    except Exception as err:
+        success = False
+        error = str(err)
+
+    if success:
+        cmd = _XCCDF_MAP[action]['cmd_pattern'].format(args.profile, policy)
+        tempdir = tempfile.mkdtemp()
+        proc = Popen(
+            shlex.split(cmd), stdout=PIPE, stderr=PIPE, cwd=tempdir)
+        (stdoutdata, error) = proc.communicate()
+        success = _OSCAP_EXIT_CODES_MAP[proc.returncode]
+        returncode = proc.returncode
+        if success:
+            caller = Caller()
+            caller.cmd('cp.push_dir', tempdir)
+            shutil.rmtree(tempdir, ignore_errors=True)
+            upload_dir = tempdir
+
+    return dict(
+        success=success,
+        upload_dir=upload_dir,
+        error=error,
+        returncode=returncode)
diff --git a/tests/unit/modules/openscap_test.py b/tests/unit/modules/openscap_test.py
new file mode 100644
index 0000000000..327af75238
--- /dev/null
+++ b/tests/unit/modules/openscap_test.py
@@ -0,0 +1,205 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+from subprocess import PIPE
+
+from salt.modules import openscap
+
+from salttesting import skipIf, TestCase
+from salttesting.mock import (
+    Mock,
+    MagicMock,
+    patch,
+    NO_MOCK,
+    NO_MOCK_REASON
+)
+
+
+@skipIf(NO_MOCK, NO_MOCK_REASON)
+class OpenscapTestCase(TestCase):
+
+    random_temp_dir = '/tmp/unique-name'
+    policy_file = '/usr/share/openscap/policy-file-xccdf.xml'
+
+    def setUp(self):
+        patchers = [
+            patch('salt.modules.openscap.Caller', MagicMock()),
+            patch('salt.modules.openscap.shutil.rmtree', Mock()),
+            patch(
+                'salt.modules.openscap.tempfile.mkdtemp',
+                Mock(return_value=self.random_temp_dir)
+            ),
+        ]
+        for patcher in patchers:
+            self.apply_patch(patcher)
+
+    def apply_patch(self, patcher):
+        patcher.start()
+        self.addCleanup(patcher.stop)
+
+    @patch(
+       'salt.modules.openscap.Popen',
+       MagicMock(
+           return_value=Mock(
+               **{'returncode': 0, 'communicate.return_value': ('', '')}
+           )
+       )
+    )
+    def test_openscap_xccdf_eval_success(self):
+        response = openscap.xccdf(
+            'eval --profile Default {0}'.format(self.policy_file))
+
+        self.assertEqual(openscap.tempfile.mkdtemp.call_count, 1)
+        expected_cmd = [
+            'oscap',
+            'xccdf',
+            'eval',
+            '--oval-results',
+            '--results', 'results.xml',
+            '--report', 'report.html',
+            '--profile', 'Default',
+            self.policy_file
+        ]
+        openscap.Popen.assert_called_once_with(
+            expected_cmd,
+            cwd=openscap.tempfile.mkdtemp.return_value,
+            stderr=PIPE,
+            stdout=PIPE)
+        openscap.Caller().cmd.assert_called_once_with(
+            'cp.push_dir', self.random_temp_dir)
+        self.assertEqual(openscap.shutil.rmtree.call_count, 1)
+        self.assertEqual(
+            response,
+            {
+                'upload_dir': self.random_temp_dir,
+                'error': '',
+                'success': True,
+                'returncode': 0
+            }
+        )
+
+    @patch(
+       'salt.modules.openscap.Popen',
+       MagicMock(
+           return_value=Mock(
+               **{'returncode': 2, 'communicate.return_value': ('', 'some error')}
+           )
+       )
+    )
+    def test_openscap_xccdf_eval_success_with_failing_rules(self):
+        response = openscap.xccdf(
+            'eval --profile Default {0}'.format(self.policy_file))
+
+        self.assertEqual(openscap.tempfile.mkdtemp.call_count, 1)
+        expected_cmd = [
+            'oscap',
+            'xccdf',
+            'eval',
+            '--oval-results',
+            '--results', 'results.xml',
+            '--report', 'report.html',
+            '--profile', 'Default',
+            self.policy_file
+        ]
+        openscap.Popen.assert_called_once_with(
+            expected_cmd,
+            cwd=openscap.tempfile.mkdtemp.return_value,
+            stderr=PIPE,
+            stdout=PIPE)
+        openscap.Caller().cmd.assert_called_once_with(
+            'cp.push_dir', self.random_temp_dir)
+        self.assertEqual(openscap.shutil.rmtree.call_count, 1)
+        self.assertEqual(
+            response,
+            {
+                'upload_dir': self.random_temp_dir,
+                'error': 'some error',
+                'success': True,
+                'returncode': 2
+            }
+        )
+
+    def test_openscap_xccdf_eval_fail_no_profile(self):
+        response = openscap.xccdf(
+            'eval --param Default /unknown/param')
+        self.assertEqual(
+            response,
+            {
+                'error': 'argument --profile is required',
+                'upload_dir': None,
+                'success': False,
+                'returncode': None
+            }
+        )
+
+    @patch(
+       'salt.modules.openscap.Popen',
+       MagicMock(
+           return_value=Mock(
+               **{'returncode': 2, 'communicate.return_value': ('', 'some error')}
+           )
+       )
+    )
+    def test_openscap_xccdf_eval_success_ignore_unknown_params(self):
+        response = openscap.xccdf(
+            'eval --profile Default --param Default /policy/file')
+        self.assertEqual(
+            response,
+            {
+                'upload_dir': self.random_temp_dir,
+                'error': 'some error',
+                'success': True,
+                'returncode': 2
+            }
+        )
+        expected_cmd = [
+            'oscap',
+            'xccdf',
+            'eval',
+            '--oval-results',
+            '--results', 'results.xml',
+            '--report', 'report.html',
+            '--profile', 'Default',
+            '/policy/file'
+        ]
+        openscap.Popen.assert_called_once_with(
+            expected_cmd,
+            cwd=openscap.tempfile.mkdtemp.return_value,
+            stderr=PIPE,
+            stdout=PIPE)
+
+    @patch(
+       'salt.modules.openscap.Popen',
+       MagicMock(
+           return_value=Mock(**{
+               'returncode': 1,
+               'communicate.return_value': ('', 'evaluation error')
+           })
+       )
+    )
+    def test_openscap_xccdf_eval_evaluation_error(self):
+        response = openscap.xccdf(
+            'eval --profile Default {0}'.format(self.policy_file))
+
+        self.assertEqual(
+            response,
+            {
+                'upload_dir': None,
+                'error': 'evaluation error',
+                'success': False,
+                'returncode': 1
+            }
+        )
+
+    def test_openscap_xccdf_eval_fail_not_implemented_action(self):
+        response = openscap.xccdf('info {0}'.format(self.policy_file))
+
+        self.assertEqual(
+            response,
+            {
+                'upload_dir': None,
+                'error': "argument action: invalid choice: 'info' (choose from 'eval')",
+                'success': False,
+                'returncode': None
+            }
+        )
-- 
2.11.0

