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