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