sailesh1993 / rpms / cloud-init

Forked from rpms/cloud-init a year ago
Clone
c36ff1
From f9564bd4477782e8cffe4be1d3c31c0338fb03b1 Mon Sep 17 00:00:00 2001
c36ff1
From: Eduardo Otubo <otubo@redhat.com>
c36ff1
Date: Mon, 5 Jul 2021 14:07:21 +0200
c36ff1
Subject: [PATCH 1/2] write passwords only to serial console, lock down
c36ff1
 cloud-init-output.log (#847)
c36ff1
c36ff1
RH-Author: Eduardo Otubo <otubo@redhat.com>
c36ff1
RH-MergeRequest: 4: write passwords only to serial console, lock down cloud-init-output.log (#847)
c36ff1
RH-Commit: [1/1] 7543b3458c01ea988e987336d84510157c00390d (otubo/cloud-init-src)
c36ff1
RH-Bugzilla: 1945892
c36ff1
RH-Acked-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
c36ff1
RH-Acked-by: Miroslav Rezanina <mrezanin@redhat.com>
c36ff1
RH-Acked-by: Mohamed Gamal Morsy <mmorsy@redhat.com>
c36ff1
c36ff1
commit b794d426b9ab43ea9d6371477466070d86e10668
c36ff1
Author: Daniel Watkins <oddbloke@ubuntu.com>
c36ff1
Date:   Fri Mar 19 10:06:42 2021 -0400
c36ff1
c36ff1
    write passwords only to serial console, lock down cloud-init-output.log (#847)
c36ff1
c36ff1
    Prior to this commit, when a user specified configuration which would
c36ff1
    generate random passwords for users, cloud-init would cause those
c36ff1
    passwords to be written to the serial console by emitting them on
c36ff1
    stderr.  In the default configuration, any stdout or stderr emitted by
c36ff1
    cloud-init is also written to `/var/log/cloud-init-output.log`.  This
c36ff1
    file is world-readable, meaning that those randomly-generated passwords
c36ff1
    were available to be read by any user with access to the system.  This
c36ff1
    presents an obvious security issue.
c36ff1
c36ff1
    This commit responds to this issue in two ways:
c36ff1
c36ff1
    * We address the direct issue by moving from writing the passwords to
c36ff1
      sys.stderr to writing them directly to /dev/console (via
c36ff1
      util.multi_log); this means that the passwords will never end up in
c36ff1
      cloud-init-output.log
c36ff1
    * To avoid future issues like this, we also modify the logging code so
c36ff1
      that any files created in a log sink subprocess will only be
c36ff1
      owner/group readable and, if it exists, will be owned by the adm
c36ff1
      group.  This results in `/var/log/cloud-init-output.log` no longer
c36ff1
      being world-readable, meaning that if there are other parts of the
c36ff1
      codebase that are emitting sensitive data intended for the serial
c36ff1
      console, that data is no longer available to all users of the system.
c36ff1
c36ff1
    LP: #1918303
c36ff1
c36ff1
Signed-off-by: Eduardo Otubo <otubo@redhat.com>
c36ff1
Signed-off-by: Miroslav Rezanina <mrezanin@redhat.com>
c36ff1
---
c36ff1
 cloudinit/config/cc_set_passwords.py          |  5 +-
c36ff1
 cloudinit/config/tests/test_set_passwords.py  | 40 +++++++++----
c36ff1
 cloudinit/tests/test_util.py                  | 56 +++++++++++++++++++
c36ff1
 cloudinit/util.py                             | 38 +++++++++++--
c36ff1
 .../modules/test_set_password.py              | 24 ++++++++
c36ff1
 tests/integration_tests/test_logging.py       | 22 ++++++++
c36ff1
 tests/unittests/test_util.py                  |  4 ++
c36ff1
 7 files changed, 173 insertions(+), 16 deletions(-)
c36ff1
 create mode 100644 tests/integration_tests/test_logging.py
c36ff1
c36ff1
diff --git a/cloudinit/config/cc_set_passwords.py b/cloudinit/config/cc_set_passwords.py
c36ff1
index d6b5682d..433de751 100755
c36ff1
--- a/cloudinit/config/cc_set_passwords.py
c36ff1
+++ b/cloudinit/config/cc_set_passwords.py
c36ff1
@@ -78,7 +78,6 @@ password.
c36ff1
 """
c36ff1
 
c36ff1
 import re
c36ff1
-import sys
c36ff1
 
c36ff1
 from cloudinit.distros import ug_util
c36ff1
 from cloudinit import log as logging
c36ff1
@@ -214,7 +213,9 @@ def handle(_name, cfg, cloud, log, args):
c36ff1
         if len(randlist):
c36ff1
             blurb = ("Set the following 'random' passwords\n",
c36ff1
                      '\n'.join(randlist))
c36ff1
-            sys.stderr.write("%s\n%s\n" % blurb)
c36ff1
+            util.multi_log(
c36ff1
+                "%s\n%s\n" % blurb, stderr=False, fallback_to_stdout=False
c36ff1
+            )
c36ff1
 
c36ff1
         if expire:
c36ff1
             expired_users = []
c36ff1
diff --git a/cloudinit/config/tests/test_set_passwords.py b/cloudinit/config/tests/test_set_passwords.py
c36ff1
index daa1ef51..bbe2ee8f 100644
c36ff1
--- a/cloudinit/config/tests/test_set_passwords.py
c36ff1
+++ b/cloudinit/config/tests/test_set_passwords.py
c36ff1
@@ -74,10 +74,6 @@ class TestSetPasswordsHandle(CiTestCase):
c36ff1
 
c36ff1
     with_logs = True
c36ff1
 
c36ff1
-    def setUp(self):
c36ff1
-        super(TestSetPasswordsHandle, self).setUp()
c36ff1
-        self.add_patch('cloudinit.config.cc_set_passwords.sys.stderr', 'm_err')
c36ff1
-
c36ff1
     def test_handle_on_empty_config(self, *args):
c36ff1
         """handle logs that no password has changed when config is empty."""
c36ff1
         cloud = self.tmp_cloud(distro='ubuntu')
c36ff1
@@ -129,10 +125,12 @@ class TestSetPasswordsHandle(CiTestCase):
c36ff1
             mock.call(['pw', 'usermod', 'ubuntu', '-p', '01-Jan-1970'])],
c36ff1
             m_subp.call_args_list)
c36ff1
 
c36ff1
+    @mock.patch(MODPATH + "util.multi_log")
c36ff1
     @mock.patch(MODPATH + "util.is_BSD")
c36ff1
     @mock.patch(MODPATH + "subp.subp")
c36ff1
-    def test_handle_on_chpasswd_list_creates_random_passwords(self, m_subp,
c36ff1
-                                                              m_is_bsd):
c36ff1
+    def test_handle_on_chpasswd_list_creates_random_passwords(
c36ff1
+        self, m_subp, m_is_bsd, m_multi_log
c36ff1
+    ):
c36ff1
         """handle parses command set random passwords."""
c36ff1
         m_is_bsd.return_value = False
c36ff1
         cloud = self.tmp_cloud(distro='ubuntu')
c36ff1
@@ -146,10 +144,32 @@ class TestSetPasswordsHandle(CiTestCase):
c36ff1
         self.assertIn(
c36ff1
             'DEBUG: Handling input for chpasswd as list.',
c36ff1
             self.logs.getvalue())
c36ff1
-        self.assertNotEqual(
c36ff1
-            [mock.call(['chpasswd'],
c36ff1
-             '\n'.join(valid_random_pwds) + '\n')],
c36ff1
-            m_subp.call_args_list)
c36ff1
+
c36ff1
+        self.assertEqual(1, m_subp.call_count)
c36ff1
+        args, _kwargs = m_subp.call_args
c36ff1
+        self.assertEqual(["chpasswd"], args[0])
c36ff1
+
c36ff1
+        stdin = args[1]
c36ff1
+        user_pass = {
c36ff1
+            user: password
c36ff1
+            for user, password
c36ff1
+            in (line.split(":") for line in stdin.splitlines())
c36ff1
+        }
c36ff1
+
c36ff1
+        self.assertEqual(1, m_multi_log.call_count)
c36ff1
+        self.assertEqual(
c36ff1
+            mock.call(mock.ANY, stderr=False, fallback_to_stdout=False),
c36ff1
+            m_multi_log.call_args
c36ff1
+        )
c36ff1
+
c36ff1
+        self.assertEqual(set(["root", "ubuntu"]), set(user_pass.keys()))
c36ff1
+        written_lines = m_multi_log.call_args[0][0].splitlines()
c36ff1
+        for password in user_pass.values():
c36ff1
+            for line in written_lines:
c36ff1
+                if password in line:
c36ff1
+                    break
c36ff1
+            else:
c36ff1
+                self.fail("Password not emitted to console")
c36ff1
 
c36ff1
 
c36ff1
 # vi: ts=4 expandtab
c36ff1
diff --git a/cloudinit/tests/test_util.py b/cloudinit/tests/test_util.py
c36ff1
index b7a302f1..e811917e 100644
c36ff1
--- a/cloudinit/tests/test_util.py
c36ff1
+++ b/cloudinit/tests/test_util.py
c36ff1
@@ -851,4 +851,60 @@ class TestEnsureFile:
c36ff1
         assert "ab" == kwargs["omode"]
c36ff1
 
c36ff1
 
c36ff1
+@mock.patch("cloudinit.util.grp.getgrnam")
c36ff1
+@mock.patch("cloudinit.util.os.setgid")
c36ff1
+@mock.patch("cloudinit.util.os.umask")
c36ff1
+class TestRedirectOutputPreexecFn:
c36ff1
+    """This tests specifically the preexec_fn used in redirect_output."""
c36ff1
+
c36ff1
+    @pytest.fixture(params=["outfmt", "errfmt"])
c36ff1
+    def preexec_fn(self, request):
c36ff1
+        """A fixture to gather the preexec_fn used by redirect_output.
c36ff1
+
c36ff1
+        This enables simpler direct testing of it, and parameterises any tests
c36ff1
+        using it to cover both the stdout and stderr code paths.
c36ff1
+        """
c36ff1
+        test_string = "| piped output to invoke subprocess"
c36ff1
+        if request.param == "outfmt":
c36ff1
+            args = (test_string, None)
c36ff1
+        elif request.param == "errfmt":
c36ff1
+            args = (None, test_string)
c36ff1
+        with mock.patch("cloudinit.util.subprocess.Popen") as m_popen:
c36ff1
+            util.redirect_output(*args)
c36ff1
+
c36ff1
+        assert 1 == m_popen.call_count
c36ff1
+        _args, kwargs = m_popen.call_args
c36ff1
+        assert "preexec_fn" in kwargs, "preexec_fn not passed to Popen"
c36ff1
+        return kwargs["preexec_fn"]
c36ff1
+
c36ff1
+    def test_preexec_fn_sets_umask(
c36ff1
+        self, m_os_umask, _m_setgid, _m_getgrnam, preexec_fn
c36ff1
+    ):
c36ff1
+        """preexec_fn should set a mask that avoids world-readable files."""
c36ff1
+        preexec_fn()
c36ff1
+
c36ff1
+        assert [mock.call(0o037)] == m_os_umask.call_args_list
c36ff1
+
c36ff1
+    def test_preexec_fn_sets_group_id_if_adm_group_present(
c36ff1
+        self, _m_os_umask, m_setgid, m_getgrnam, preexec_fn
c36ff1
+    ):
c36ff1
+        """We should setgrp to adm if present, so files are owned by them."""
c36ff1
+        fake_group = mock.Mock(gr_gid=mock.sentinel.gr_gid)
c36ff1
+        m_getgrnam.return_value = fake_group
c36ff1
+
c36ff1
+        preexec_fn()
c36ff1
+
c36ff1
+        assert [mock.call("adm")] == m_getgrnam.call_args_list
c36ff1
+        assert [mock.call(mock.sentinel.gr_gid)] == m_setgid.call_args_list
c36ff1
+
c36ff1
+    def test_preexec_fn_handles_absent_adm_group_gracefully(
c36ff1
+        self, _m_os_umask, m_setgid, m_getgrnam, preexec_fn
c36ff1
+    ):
c36ff1
+        """We should handle an absent adm group gracefully."""
c36ff1
+        m_getgrnam.side_effect = KeyError("getgrnam(): name not found: 'adm'")
c36ff1
+
c36ff1
+        preexec_fn()
c36ff1
+
c36ff1
+        assert 0 == m_setgid.call_count
c36ff1
+
c36ff1
 # vi: ts=4 expandtab
c36ff1
diff --git a/cloudinit/util.py b/cloudinit/util.py
c36ff1
index 769f3425..4e0a72db 100644
c36ff1
--- a/cloudinit/util.py
c36ff1
+++ b/cloudinit/util.py
c36ff1
@@ -359,7 +359,7 @@ def find_modules(root_dir):
c36ff1
 
c36ff1
 
c36ff1
 def multi_log(text, console=True, stderr=True,
c36ff1
-              log=None, log_level=logging.DEBUG):
c36ff1
+              log=None, log_level=logging.DEBUG, fallback_to_stdout=True):
c36ff1
     if stderr:
c36ff1
         sys.stderr.write(text)
c36ff1
     if console:
c36ff1
@@ -368,7 +368,7 @@ def multi_log(text, console=True, stderr=True,
c36ff1
             with open(conpath, 'w') as wfh:
c36ff1
                 wfh.write(text)
c36ff1
                 wfh.flush()
c36ff1
-        else:
c36ff1
+        elif fallback_to_stdout:
c36ff1
             # A container may lack /dev/console (arguably a container bug).  If
c36ff1
             # it does not exist, then write output to stdout.  this will result
c36ff1
             # in duplicate stderr and stdout messages if stderr was True.
c36ff1
@@ -623,6 +623,26 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None):
c36ff1
     if not o_err:
c36ff1
         o_err = sys.stderr
c36ff1
 
c36ff1
+    # pylint: disable=subprocess-popen-preexec-fn
c36ff1
+    def set_subprocess_umask_and_gid():
c36ff1
+        """Reconfigure umask and group ID to create output files securely.
c36ff1
+
c36ff1
+        This is passed to subprocess.Popen as preexec_fn, so it is executed in
c36ff1
+        the context of the newly-created process.  It:
c36ff1
+
c36ff1
+        * sets the umask of the process so created files aren't world-readable
c36ff1
+        * if an adm group exists in the system, sets that as the process' GID
c36ff1
+          (so that the created file(s) are owned by root:adm)
c36ff1
+        """
c36ff1
+        os.umask(0o037)
c36ff1
+        try:
c36ff1
+            group_id = grp.getgrnam("adm").gr_gid
c36ff1
+        except KeyError:
c36ff1
+            # No adm group, don't set a group
c36ff1
+            pass
c36ff1
+        else:
c36ff1
+            os.setgid(group_id)
c36ff1
+
c36ff1
     if outfmt:
c36ff1
         LOG.debug("Redirecting %s to %s", o_out, outfmt)
c36ff1
         (mode, arg) = outfmt.split(" ", 1)
c36ff1
@@ -632,7 +652,12 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None):
c36ff1
                 owith = "wb"
c36ff1
             new_fp = open(arg, owith)
c36ff1
         elif mode == "|":
c36ff1
-            proc = subprocess.Popen(arg, shell=True, stdin=subprocess.PIPE)
c36ff1
+            proc = subprocess.Popen(
c36ff1
+                arg,
c36ff1
+                shell=True,
c36ff1
+                stdin=subprocess.PIPE,
c36ff1
+                preexec_fn=set_subprocess_umask_and_gid,
c36ff1
+            )
c36ff1
             new_fp = proc.stdin
c36ff1
         else:
c36ff1
             raise TypeError("Invalid type for output format: %s" % outfmt)
c36ff1
@@ -654,7 +679,12 @@ def redirect_output(outfmt, errfmt, o_out=None, o_err=None):
c36ff1
                 owith = "wb"
c36ff1
             new_fp = open(arg, owith)
c36ff1
         elif mode == "|":
c36ff1
-            proc = subprocess.Popen(arg, shell=True, stdin=subprocess.PIPE)
c36ff1
+            proc = subprocess.Popen(
c36ff1
+                arg,
c36ff1
+                shell=True,
c36ff1
+                stdin=subprocess.PIPE,
c36ff1
+                preexec_fn=set_subprocess_umask_and_gid,
c36ff1
+            )
c36ff1
             new_fp = proc.stdin
c36ff1
         else:
c36ff1
             raise TypeError("Invalid type for error format: %s" % errfmt)
c36ff1
diff --git a/tests/integration_tests/modules/test_set_password.py b/tests/integration_tests/modules/test_set_password.py
c36ff1
index b13f76fb..d7cf91a5 100644
c36ff1
--- a/tests/integration_tests/modules/test_set_password.py
c36ff1
+++ b/tests/integration_tests/modules/test_set_password.py
c36ff1
@@ -116,6 +116,30 @@ class Mixin:
c36ff1
         # Which are not the same
c36ff1
         assert shadow_users["harry"] != shadow_users["dick"]
c36ff1
 
c36ff1
+    def test_random_passwords_not_stored_in_cloud_init_output_log(
c36ff1
+        self, class_client
c36ff1
+    ):
c36ff1
+        """We should not emit passwords to the in-instance log file.
c36ff1
+
c36ff1
+        LP: #1918303
c36ff1
+        """
c36ff1
+        cloud_init_output = class_client.read_from_file(
c36ff1
+            "/var/log/cloud-init-output.log"
c36ff1
+        )
c36ff1
+        assert "dick:" not in cloud_init_output
c36ff1
+        assert "harry:" not in cloud_init_output
c36ff1
+
c36ff1
+    def test_random_passwords_emitted_to_serial_console(self, class_client):
c36ff1
+        """We should emit passwords to the serial console. (LP: #1918303)"""
c36ff1
+        try:
c36ff1
+            console_log = class_client.instance.console_log()
c36ff1
+        except NotImplementedError:
c36ff1
+            # Assume that an exception here means that we can't use the console
c36ff1
+            # log
c36ff1
+            pytest.skip("NotImplementedError when requesting console log")
c36ff1
+        assert "dick:" in console_log
c36ff1
+        assert "harry:" in console_log
c36ff1
+
c36ff1
     def test_explicit_password_set_correctly(self, class_client):
c36ff1
         """Test that an explicitly-specified password is set correctly."""
c36ff1
         shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client)
c36ff1
diff --git a/tests/integration_tests/test_logging.py b/tests/integration_tests/test_logging.py
c36ff1
new file mode 100644
c36ff1
index 00000000..b31a0434
c36ff1
--- /dev/null
c36ff1
+++ b/tests/integration_tests/test_logging.py
c36ff1
@@ -0,0 +1,22 @@
c36ff1
+"""Integration tests relating to cloud-init's logging."""
c36ff1
+
c36ff1
+
c36ff1
+class TestVarLogCloudInitOutput:
c36ff1
+    """Integration tests relating to /var/log/cloud-init-output.log."""
c36ff1
+
c36ff1
+    def test_var_log_cloud_init_output_not_world_readable(self, client):
c36ff1
+        """
c36ff1
+        The log can contain sensitive data, it shouldn't be world-readable.
c36ff1
+
c36ff1
+        LP: #1918303
c36ff1
+        """
c36ff1
+        # Check the file exists
c36ff1
+        assert client.execute("test -f /var/log/cloud-init-output.log").ok
c36ff1
+
c36ff1
+        # Check its permissions are as we expect
c36ff1
+        perms, user, group = client.execute(
c36ff1
+            "stat -c %a:%U:%G /var/log/cloud-init-output.log"
c36ff1
+        ).split(":")
c36ff1
+        assert "640" == perms
c36ff1
+        assert "root" == user
c36ff1
+        assert "adm" == group
c36ff1
diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py
c36ff1
index 857629f1..e5292001 100644
c36ff1
--- a/tests/unittests/test_util.py
c36ff1
+++ b/tests/unittests/test_util.py
c36ff1
@@ -572,6 +572,10 @@ class TestMultiLog(helpers.FilesystemMockingTestCase):
c36ff1
         util.multi_log(logged_string)
c36ff1
         self.assertEqual(logged_string, self.stdout.getvalue())
c36ff1
 
c36ff1
+    def test_logs_dont_go_to_stdout_if_fallback_to_stdout_is_false(self):
c36ff1
+        util.multi_log('something', fallback_to_stdout=False)
c36ff1
+        self.assertEqual('', self.stdout.getvalue())
c36ff1
+
c36ff1
     def test_logs_go_to_log_if_given(self):
c36ff1
         log = mock.MagicMock()
c36ff1
         logged_string = 'something very important'
c36ff1
-- 
c36ff1
2.27.0
c36ff1