From 879945f56103d937a7fee84bfe7662dc2a5be708 Mon Sep 17 00:00:00 2001 From: sxt1001 Date: Thu, 17 Oct 2024 20:45:07 +0800 Subject: [PATCH] feat: Ensure random passwords contain multiple character types (#5815) Reference:https://github.com/canonical/cloud-init/commit/879945f56103d937a7fee84bfe7662dc2a5be708 Conflict:NA The complexity of the random password generated by the rand_user_password() method may not meet the security configuration requirements of the system authentication module. This can cause chpasswd to fail. This commit ensures we generate a password using 4 different character classes. Fixes GH-5814 Co-authored-by: James Falcon --- cloudinit/config/cc_set_passwords.py | 33 +++++++++++++--- .../unittests/config/test_cc_set_passwords.py | 38 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/cloudinit/config/cc_set_passwords.py b/cloudinit/config/cc_set_passwords.py index 24d8267..d46c7f2 100644 --- a/cloudinit/config/cc_set_passwords.py +++ b/cloudinit/config/cc_set_passwords.py @@ -9,7 +9,8 @@ import logging import re -from string import ascii_letters, digits +import random +import string from textwrap import dedent from typing import List @@ -89,9 +90,6 @@ __doc__ = get_meta_doc(meta) LOG = logging.getLogger(__name__) -# We are removing certain 'painful' letters/numbers -PW_SET = "".join([x for x in ascii_letters + digits if x not in "loLOI01"]) - def get_users_by_type(users_list: list, pw_type: str) -> list: """either password or type: RANDOM is required, user is always required""" @@ -307,4 +305,29 @@ def handle(name: str, cfg: Config, cloud: Cloud, args: list) -> None: def rand_user_password(pwlen=20): - return util.rand_str(pwlen, select_from=PW_SET) + if pwlen < 4: + raise ValueError("Password length must be at least 4 characters.") + + # There are often restrictions on the minimum number of character + # classes required in a password, so ensure we at least one character + # from each class. + res_rand_list = [ + random.choice(string.digits), + random.choice(string.ascii_lowercase), + random.choice(string.ascii_uppercase), + random.choice(string.punctuation), + ] + + res_rand_list.extend( + list( + util.rand_str( + pwlen - len(res_rand_list), + select_from=string.digits + + string.ascii_lowercase + + string.ascii_uppercase + + string.punctuation, + ) + ) + ) + random.shuffle(res_rand_list) + return "".join(res_rand_list) diff --git a/tests/unittests/config/test_cc_set_passwords.py b/tests/unittests/config/test_cc_set_passwords.py index ef34a8c..b5d561c 100644 --- a/tests/unittests/config/test_cc_set_passwords.py +++ b/tests/unittests/config/test_cc_set_passwords.py @@ -1,6 +1,7 @@ # This file is part of cloud-init. See LICENSE file for license information. import logging +import string from unittest import mock import pytest @@ -555,6 +556,43 @@ class TestExpire: assert "Expired passwords" not in caplog.text +class TestRandUserPassword: + def _get_str_class_num(self, str): + return sum( + [ + any(c.islower() for c in str), + any(c.isupper() for c in str), + any(c.isupper() for c in str), + any(c in string.punctuation for c in str), + ] + ) + + @pytest.mark.parametrize( + "strlen, expected_result", + [ + (1, ValueError), + (2, ValueError), + (3, ValueError), + (4, 4), + (5, 4), + (5, 4), + (6, 4), + (20, 4), + ], + ) + def test_rand_user_password(self, strlen, expected_result): + if expected_result is ValueError: + with pytest.raises( + expected_result, + match="Password length must be at least 4 characters.", + ): + setpass.rand_user_password(strlen) + else: + rand_password = setpass.rand_user_password(strlen) + assert len(rand_password) == strlen + assert self._get_str_class_num(rand_password) == expected_result + + class TestSetPasswordsSchema: @pytest.mark.parametrize( "config, expectation", -- 2.33.0