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