CVE-2023-1786 (published 2023-04-26T23:15Z)

Sensitive data could be exposed in logs of cloud-init before version 23.1.2. An attacker could use this information to find hashed passwords and possibly escalate their privilege.

CVSS v3 base score: 5.5, MEDIUM (CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-532
cwe_name: Insertion of Sensitive Information into Log File
description: Information written to log files can be of a sensitive nature and give valuable guidance to an attacker or expose sensitive user information.
extended_description: 
            While logging all information may be helpful during development stages, it is important that logging levels be set appropriately before a product ships so that sensitive user data and system information are not accidentally exposed to potential attackers.
            Different log files may be produced and stored for:
            
               Server log files (e.g. server.log).  This can give information on whatever application left the file. Usually this can give full path names and system information, and sometimes usernames and passwords.
               log files that are used for debugging
            
         
         

Fix information:
repo_url: https://github.com/canonical/cloud-init
commit_details: repo_url: https://github.com/canonical/cloud-init
author: James Falcon
author_date: 2023-04-26 15:11:55-05:00
msg: Make user/vendor data sensitive and remove log permissions (#2144)

Because user data and vendor data may contain sensitive information,
this commit ensures that any user data or vendor data written to
instance-data.json gets redacted and is only available to root user.

Also, modify the permissions of cloud-init.log to be 640, so that
sensitive data leaked to the log isn't world readable.
Additionally, remove the logging of user data and vendor data to
cloud-init.log from the Vultr datasource.

LP: #2013967
CVE: CVE-2023-1786
repository: repo_url: https://github.com/canonical/cloud-init
repo_name: canonical/cloud-init
description: Official upstream for the cloud-init: cloud instance initialization
repo_language: Python
owner: canonical
file_changes: filename: DataSourceLXD.py
change_type: ModificationType.MODIFY
diff: @@ -14,7 +14,7 @@ import stat
 import time
 from enum import Flag, auto
 from json.decoder import JSONDecodeError
-from typing import Any, Dict, List, Optional, Union, cast
+from typing import Any, Dict, List, Optional, Tuple, Union, cast
 
 import requests
 from requests.adapters import HTTPAdapter
@@ -168,11 +168,14 @@ class DataSourceLXD(sources.DataSource):
     _network_config: Union[Dict, str] = sources.UNSET
     _crawled_metadata: Union[Dict, str] = sources.UNSET
 
-    sensitive_metadata_keys = (
-        "merged_cfg",
+    sensitive_metadata_keys: Tuple[
+        str, ...
+    ] = sources.DataSource.sensitive_metadata_keys + (
         "user.meta-data",
         "user.vendor-data",
         "user.user-data",
+        "cloud-init.user-data",
+        "cloud-init.vendor-data",
     )
 
     skip_hotplug_detect = True

programming_language: Python
filename: DataSourceVultr.py
change_type: ModificationType.MODIFY
diff: @@ -5,6 +5,8 @@
 # Vultr Metadata API:
 # https://www.vultr.com/metadata/
 
+from typing import Tuple
+
 import cloudinit.sources.helpers.vultr as vultr
 from cloudinit import log as log
 from cloudinit import sources, stages, util, version
@@ -28,6 +30,10 @@ class DataSourceVultr(sources.DataSource):
 
     dsname = "Vultr"
 
+    sensitive_metadata_keys: Tuple[
+        str, ...
+    ] = sources.DataSource.sensitive_metadata_keys + ("startup-script",)
+
     def __init__(self, sys_cfg, distro, paths):
         super(DataSourceVultr, self).__init__(sys_cfg, distro, paths)
         self.ds_cfg = util.mergemanydict(
@@ -54,13 +60,8 @@ class DataSourceVultr(sources.DataSource):
         self.get_datasource_data(self.metadata)
 
         # Dump some data so diagnosing failures is manageable
-        LOG.debug("Vultr Vendor Config:")
-        LOG.debug(util.json_dumps(self.metadata["vendor-data"]))
         LOG.debug("SUBID: %s", self.metadata["instance-id"])
         LOG.debug("Hostname: %s", self.metadata["local-hostname"])
-        if self.userdata_raw is not None:
-            LOG.debug("User-Data:")
-            LOG.debug(self.userdata_raw)
 
         return True
 
@@ -155,6 +156,3 @@ if __name__ == "__main__":
     )
     config = md["vendor-data"]
     sysinfo = vultr.get_sysinfo()
-
-    print(util.json_dumps(sysinfo))
-    print(util.json_dumps(config))

programming_language: Python
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -111,7 +111,10 @@ def process_instance_metadata(metadata, key_path="", sensitive_keys=()):
             sub_key_path = key_path + "/" + key
         else:
             sub_key_path = key
-        if key in sensitive_keys or sub_key_path in sensitive_keys:
+        if (
+            key.lower() in sensitive_keys
+            or sub_key_path.lower() in sensitive_keys
+        ):
             sens_keys.append(sub_key_path)
         if isinstance(val, str) and val.startswith("ci-b64:"):
             base64_encoded_keys.append(sub_key_path)
@@ -133,6 +136,12 @@ def redact_sensitive_keys(metadata, redact_value=REDACT_SENSITIVE_VALUE):
 
     Replace any keys values listed in 'sensitive_keys' with redact_value.
     """
+    # While 'sensitive_keys' should already sanitized to only include what
+    # is in metadata, it is possible keys will overlap. For example, if
+    # "merged_cfg" and "merged_cfg/ds/userdata" both match, it's possible that
+    # "merged_cfg" will get replaced first, meaning "merged_cfg/ds/userdata"
+    # no longer represents a valid key.
+    # Thus, we still need to do membership checks in this function.
     if not metadata.get("sensitive_keys", []):
         return metadata
     md_copy = copy.deepcopy(metadata)
@@ -140,9 +149,14 @@ def redact_sensitive_keys(metadata, redact_value=REDACT_SENSITIVE_VALUE):
         path_parts = key_path.split("/")
         obj = md_copy
         for path in path_parts:
-            if isinstance(obj[path], dict) and path != path_parts[-1]:
+            if (
+                path in obj
+                and isinstance(obj[path], dict)
+                and path != path_parts[-1]
+            ):
                 obj = obj[path]
-        obj[path] = redact_value
+        if path in obj:
+            obj[path] = redact_value
     return md_copy
 
 
@@ -250,6 +264,14 @@ class DataSource(CloudInitPickleMixin, metaclass=abc.ABCMeta):
     sensitive_metadata_keys: Tuple[str, ...] = (
         "merged_cfg",
         "security-credentials",
+        "userdata",
+        "user-data",
+        "user_data",
+        "vendordata",
+        "vendor-data",
+        # Provide ds/vendor_data to avoid redacting top-level
+        #  "vendor_data": {enabled: True}
+        "ds/vendor_data",
     )
 
     # True on datasources that may not see hotplugged devices reflected

programming_language: Python
filename: stages.py
change_type: ModificationType.MODIFY
diff: @@ -203,7 +203,9 @@ class Init:
         util.ensure_dirs(self._initial_subdirs())
         log_file = util.get_cfg_option_str(self.cfg, "def_log_file")
         if log_file:
-            util.ensure_file(log_file, mode=0o640, preserve_mode=True)
+            # At this point the log file should have already been created
+            # in the setupLogging function of log.py
+            util.ensure_file(log_file, mode=0o640, preserve_mode=False)
             perms = self.cfg.get("syslog_fix_perms")
             if not perms:
                 perms = {}

programming_language: Python
filename: test_init.py
change_type: ModificationType.MODIFY
diff: @@ -458,12 +458,24 @@ class TestDataSource(CiTestCase):
                         "cred2": "othersekret",
                     }
                 },
+                "someother": {
+                    "nested": {
+                        "userData": "HIDE ME",
+                    }
+                },
+                "VENDOR-DAta": "HIDE ME TOO",
             },
         )
         self.assertCountEqual(
             (
                 "merged_cfg",
                 "security-credentials",
+                "userdata",
+                "user-data",
+                "user_data",
+                "vendordata",
+                "vendor-data",
+                "ds/vendor_data",
             ),
             datasource.sensitive_metadata_keys,
         )
@@ -490,7 +502,9 @@ class TestDataSource(CiTestCase):
             "base64_encoded_keys": [],
             "merged_cfg": REDACT_SENSITIVE_VALUE,
             "sensitive_keys": [
+                "ds/meta_data/VENDOR-DAta",
                 "ds/meta_data/some/security-credentials",
+                "ds/meta_data/someother/nested/userData",
                 "merged_cfg",
             ],
             "sys_info": sys_info,
@@ -500,6 +514,7 @@ class TestDataSource(CiTestCase):
                 "availability_zone": "myaz",
                 "cloud-name": "subclasscloudname",
                 "cloud_name": "subclasscloudname",
+                "cloud_id": "subclasscloudname",
                 "distro": "ubuntu",
                 "distro_release": "focal",
                 "distro_version": "20.04",
@@ -522,14 +537,18 @@ class TestDataSource(CiTestCase):
             "ds": {
                 "_doc": EXPERIMENTAL_TEXT,
                 "meta_data": {
+                    "VENDOR-DAta": REDACT_SENSITIVE_VALUE,
                     "availability_zone": "myaz",
                     "local-hostname": "test-subclass-hostname",
                     "region": "myregion",
                     "some": {"security-credentials": REDACT_SENSITIVE_VALUE},
+                    "someother": {
+                        "nested": {"userData": REDACT_SENSITIVE_VALUE}
+                    },
                 },
             },
         }
-        self.assertCountEqual(expected, redacted)
+        self.assertEqual(expected, redacted)
         file_stat = os.stat(json_file)
         self.assertEqual(0o644, stat.S_IMODE(file_stat.st_mode))
 
@@ -574,6 +593,12 @@ class TestDataSource(CiTestCase):
             (
                 "merged_cfg",
                 "security-credentials",
+                "userdata",
+                "user-data",
+                "user_data",
+                "vendordata",
+                "vendor-data",
+                "ds/vendor_data",
             ),
             datasource.sensitive_metadata_keys,
         )

programming_language: Python
filename: test_stages.py
change_type: ModificationType.MODIFY
diff: @@ -606,19 +606,23 @@ class TestInit_InitializeFilesystem:
         # Assert we create it 0o640  by default if it doesn't already exist
         assert 0o640 == stat.S_IMODE(log_file.stat().mode)
 
-    def test_existing_file_permissions_are_not_modified(self, init, tmpdir):
-        """If the log file already exists, we should not modify its permissions
+    def test_existing_file_permissions(self, init, tmpdir):
+        """Test file permissions are set as expected.
+
+        CIS Hardening requires 640 permissions. These permissions are
+        currently hardcoded on every boot, but if there's ever a reason
+        to change this, we need to then ensure that they
+        are *not* set every boot.
 
         See https://bugs.launchpad.net/cloud-init/+bug/1900837.
         """
-        # Use a mode that will never be made the default so this test will
-        # always be valid
-        mode = 0o606
         log_file = tmpdir.join("cloud-init.log")
         log_file.ensure()
-        log_file.chmod(mode)
+        # Use a mode that will never be made the default so this test will
+        # always be valid
+        log_file.chmod(0o606)
         init._cfg = {"def_log_file": str(log_file)}
 
         init._initialize_filesystem()
 
-        assert mode == stat.S_IMODE(log_file.stat().mode)
+        assert 0o640 == stat.S_IMODE(log_file.stat().mode)

programming_language: Python