diff --git a/cloudinit/config/cc_set_passwords.py b/cloudinit/config/cc_set_passwords.py index 65856907041..8cb6a1ec515 100644 --- a/cloudinit/config/cc_set_passwords.py +++ b/cloudinit/config/cc_set_passwords.py @@ -8,8 +8,9 @@ """Set Passwords: Set user passwords and enable/disable SSH password auth""" import logging +import random import re -from string import ascii_letters, digits +import string from typing import List from cloudinit import features, lifecycle, subp, util @@ -30,9 +31,6 @@ 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""" @@ -248,4 +246,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 bc6f4cbda30..73cb3d4906c 100644 --- a/tests/unittests/config/test_cc_set_passwords.py +++ b/tests/unittests/config/test_cc_set_passwords.py @@ -2,6 +2,7 @@ import copy import logging +import string from unittest import mock import pytest @@ -559,6 +560,43 @@ def test_expire_old_behavior(self, cfg, mocker, caplog): 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",