Skip to content

Commit f17d55d

Browse files
davidpil2002yxieca
authored andcommitted
Add support for Password Hardening (#10323)
- Why I did it New security feature for enforcing strong passwords when login or changing passwords of existing users into the switch. - How I did it By using mainly Linux package named pam-cracklib that support the enforcement of user passwords, the daemon named hostcfgd, will support add/modify password policies that enforce and strengthen the user passwords. - How to verify it Manually Verification- 1. Enable the feature, using the new sonic-cli command passw-hardening or manually add the password hardening table like shown in HLD by using redis-cli command 2. Change password policies manually like in step 1. Notes: password hardening CLI can be found in sonic-utilities repo- P.R: Add support for Password Hardening sonic-utilities#2121 code config path: config/plugins/sonic-passwh_yang.py code show path: show/plugins/sonic-passwh_yang.py 3. Create a new user (using adduser command) or modify an existing password by using passwd command in the terminal. And it will now request a strong password instead of default linux policies. Automatic Verification - Unitest: This PR contained unitest that cover: 1. test default init values of the feature in PAM files 2. test all the types of classes policies supported by the feature in PAM files 3. test aging policy configuration in PAM files
1 parent ab87fb8 commit f17d55d

File tree

12 files changed

+2155
-2
lines changed

12 files changed

+2155
-2
lines changed

files/build_templates/sonic_debian_extension.j2

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,9 @@ fi
266266
sudo dpkg --root=$FILESYSTEM_ROOT -i $debs_path/sonic-device-data_*.deb || \
267267
sudo LANG=C DEBIAN_FRONTEND=noninteractive chroot $FILESYSTEM_ROOT apt-get -y install -f
268268

269+
# package for supporting password hardening
270+
sudo LANG=C DEBIAN_FRONTEND=noninteractive chroot $FILESYSTEM_ROOT apt-get -y install libpam-cracklib
271+
269272
# Install pam-tacplus and nss-tacplus
270273
sudo dpkg --root=$FILESYSTEM_ROOT -i $debs_path/libtac2_*.deb || \
271274
sudo LANG=C DEBIAN_FRONTEND=noninteractive chroot $FILESYSTEM_ROOT apt-get -y install -f
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#THIS IS AN AUTO-GENERATED FILE
2+
#
3+
# /etc/pam.d/common-password - password-related modules common to all services
4+
#
5+
# This file is included from other service-specific PAM config files,
6+
# and should contain a list of modules that define the services to be
7+
# used to change user passwords. The default is pam_unix.
8+
9+
# Explanation of pam_unix options:
10+
# The "yescrypt" option enables
11+
#hashed passwords using the yescrypt algorithm, introduced in Debian
12+
#11. Without this option, the default is Unix crypt. Prior releases
13+
#used the option "sha512"; if a shadow password hash will be shared
14+
#between Debian 11 and older releases replace "yescrypt" with "sha512"
15+
#for compatibility . The "obscure" option replaces the old
16+
#`OBSCURE_CHECKS_ENAB' option in login.defs. See the pam_unix manpage
17+
#for other options.
18+
19+
# As of pam 1.0.1-6, this file is managed by pam-auth-update by default.
20+
# To take advantage of this, it is recommended that you configure any
21+
# local modules either before or after the default block, and use
22+
# pam-auth-update to manage selection of other modules. See
23+
# pam-auth-update(8) for details.
24+
25+
# here are the per-package modules (the "Primary" block)
26+
27+
{% if passw_policies %}
28+
{% if passw_policies['state'] == 'enabled' %}
29+
password requisite pam_cracklib.so retry=3 maxrepeat=0 {% if passw_policies['len_min'] %}minlen={{passw_policies['len_min']}}{% endif %} {% if passw_policies['upper_class'] %}ucredit=-1{% else %}ucredit=0{% endif %} {% if passw_policies['lower_class'] %}lcredit=-1{% else %}lcredit=0{% endif %} {% if passw_policies['digits_class'] %}dcredit=-1{% else %}dcredit=0{% endif %} {% if passw_policies['special_class'] %}ocredit=-1{% else %}ocredit=0{% endif %} {% if passw_policies['reject_user_passw_match'] %}reject_username{% endif %} enforce_for_root
30+
31+
password required pam_pwhistory.so {% if passw_policies['history_cnt'] %}remember={{passw_policies['history_cnt']}}{% endif %} use_authtok enforce_for_root
32+
{% endif %}
33+
{% endif %}
34+
35+
password [success=1 default=ignore] pam_unix.so obscure yescrypt
36+
# here's the fallback if no module succeeds
37+
password requisite pam_deny.so
38+
# prime the stack with a positive return value if there isn't one already;
39+
# this avoids us returning an error just because nothing sets a success code
40+
# since the modules above will each just jump around
41+
password required pam_permit.so
42+
# and here are more per-package modules (the "Additional" block)
43+
# end of pam-auth-update config

src/sonic-host-services/scripts/hostcfgd

Lines changed: 209 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ import sys
88
import subprocess
99
import syslog
1010
import signal
11-
11+
import re
1212
import jinja2
1313
from sonic_py_common import device_info
1414
from swsscommon.swsscommon import ConfigDBConnector, DBConnector, Table
1515

1616
# FILE
1717
PAM_AUTH_CONF = "/etc/pam.d/common-auth-sonic"
1818
PAM_AUTH_CONF_TEMPLATE = "/usr/share/sonic/templates/common-auth-sonic.j2"
19+
PAM_PASSWORD_CONF = "/etc/pam.d/common-password"
20+
PAM_PASSWORD_CONF_TEMPLATE = "/usr/share/sonic/templates/common-password.j2"
1921
NSS_TACPLUS_CONF = "/etc/tacplus_nss.conf"
2022
NSS_TACPLUS_CONF_TEMPLATE = "/usr/share/sonic/templates/tacplus_nss.conf.j2"
2123
NSS_RADIUS_CONF = "/etc/radius_nss.conf"
@@ -24,6 +26,16 @@ PAM_RADIUS_AUTH_CONF_TEMPLATE = "/usr/share/sonic/templates/pam_radius_auth.conf
2426
NSS_CONF = "/etc/nsswitch.conf"
2527
ETC_PAMD_SSHD = "/etc/pam.d/sshd"
2628
ETC_PAMD_LOGIN = "/etc/pam.d/login"
29+
ETC_LOGIN_DEF = "/etc/login.defs"
30+
31+
# Linux login.def default values (password hardening disable)
32+
LINUX_DEFAULT_PASS_MAX_DAYS = 99999
33+
LINUX_DEFAULT_PASS_WARN_AGE = 7
34+
35+
ACCOUNT_NAME = 0 # index of account name
36+
AGE_DICT = { 'MAX_DAYS': {'REGEX_DAYS': r'^PASS_MAX_DAYS[ \t]*(?P<max_days>\d*)', 'DAYS': 'max_days', 'CHAGE_FLAG': '-M '},
37+
'WARN_DAYS': {'REGEX_DAYS': r'^PASS_WARN_AGE[ \t]*(?P<warn_days>\d*)', 'DAYS': 'warn_days', 'CHAGE_FLAG': '-W '}
38+
}
2739
PAM_LIMITS_CONF_TEMPLATE = "/usr/share/sonic/templates/pam_limits.j2"
2840
LIMITS_CONF_TEMPLATE = "/usr/share/sonic/templates/limits.conf.j2"
2941
PAM_LIMITS_CONF = "/etc/pam.d/pam-limits-conf"
@@ -85,8 +97,10 @@ def run_cmd(cmd, log_err=True, raise_exception=False):
8597
def is_true(val):
8698
if val == 'True' or val == 'true':
8799
return True
88-
else:
100+
elif val == 'False' or val == 'false':
89101
return False
102+
syslog.syslog(syslog.LOG_ERR, "Failed to get bool value, instead val= {}".format(val))
103+
return False
90104

91105

92106
def is_vlan_sub_interface(ifname):
@@ -867,6 +881,189 @@ class AaaCfg(object):
867881
.format(err.cmd, err.returncode, err.output))
868882

869883

884+
class PasswHardening(object):
885+
def __init__(self):
886+
self.passw_policies_default = {}
887+
self.passw_policies = {}
888+
889+
self.debug = False
890+
self.trace = False
891+
892+
def load(self, policies_conf):
893+
for row in policies_conf:
894+
self.passw_policies_update(row, policies_conf[row], modify_conf=False)
895+
896+
self.modify_passw_conf_file()
897+
898+
def passw_policies_update(self, key, data, modify_conf=True):
899+
syslog.syslog(syslog.LOG_DEBUG, "passw_policies_update - key: {}".format(key))
900+
syslog.syslog(syslog.LOG_DEBUG, "passw_policies_update - data: {}".format(data))
901+
902+
if data == {}:
903+
self.passw_policies = {}
904+
else:
905+
if 'reject_user_passw_match' in data:
906+
data['reject_user_passw_match'] = is_true(data['reject_user_passw_match'])
907+
if 'lower_class' in data:
908+
data['lower_class'] = is_true(data['lower_class'])
909+
if 'upper_class' in data:
910+
data['upper_class'] = is_true(data['upper_class'])
911+
if 'digits_class' in data:
912+
data['digits_class'] = is_true(data['digits_class'])
913+
if 'special_class' in data:
914+
data['special_class'] = is_true(data['special_class'])
915+
916+
if key == 'POLICIES':
917+
self.passw_policies = data
918+
919+
if modify_conf:
920+
self.modify_passw_conf_file()
921+
922+
def modify_single_file_inplace(self, filename, operations=None):
923+
if operations:
924+
cmd = "sed -i {0} {1}".format(' -i '.join(operations), filename)
925+
syslog.syslog(syslog.LOG_DEBUG, "modify_single_file_inplace: cmd - {}".format(cmd))
926+
os.system(cmd)
927+
928+
def set_passw_hardening_policies(self, passw_policies):
929+
# Password Hardening flow
930+
# When feature is enabled, the passw_policies from CONFIG_DB will be set in the pam files /etc/pam.d/common-password and /etc/login.def.
931+
# When the feature is disabled, the files above will be generate with the linux default (without secured passw_policies).
932+
syslog.syslog(syslog.LOG_DEBUG, "modify_conf_file: passw_policies - {}".format(passw_policies))
933+
934+
template_passwh_file = os.path.abspath(PAM_PASSWORD_CONF_TEMPLATE)
935+
env = jinja2.Environment(loader=jinja2.FileSystemLoader('/'), trim_blocks=True)
936+
env.filters['sub'] = sub
937+
template_passwh = env.get_template(template_passwh_file)
938+
939+
# Render common-password file with passw hardening policies if any. Other render without them.
940+
pam_passwh_conf = template_passwh.render(debug=self.debug, passw_policies=passw_policies)
941+
942+
# Use rename(), which is atomic (on the same fs) to avoid empty file
943+
with open(PAM_PASSWORD_CONF + ".tmp", 'w') as f:
944+
f.write(pam_passwh_conf)
945+
os.chmod(PAM_PASSWORD_CONF + ".tmp", 0o644)
946+
os.rename(PAM_PASSWORD_CONF + ".tmp", PAM_PASSWORD_CONF)
947+
948+
# Age policy
949+
# When feature disabled or age policy disabled, expiry days policy should be as linux default, other, accoriding CONFIG_DB.
950+
curr_expiration = LINUX_DEFAULT_PASS_MAX_DAYS
951+
curr_expiration_warning = LINUX_DEFAULT_PASS_WARN_AGE
952+
953+
if passw_policies:
954+
if 'state' in passw_policies:
955+
if passw_policies['state'] == 'enabled':
956+
if 'expiration' in passw_policies:
957+
if int(self.passw_policies['expiration']) != 0: # value '0' meaning age policy is disabled
958+
# the logic is to modify the expiration time according the last updated modificatiion
959+
#
960+
curr_expiration = int(passw_policies['expiration'])
961+
962+
if 'expiration_warning' in passw_policies:
963+
if int(self.passw_policies['expiration_warning']) != 0: # value '0' meaning age policy is disabled
964+
curr_expiration_warning = int(passw_policies['expiration_warning'])
965+
966+
if self.is_passwd_aging_expire_update(curr_expiration, 'MAX_DAYS'):
967+
# Set aging policy for existing users
968+
self.passwd_aging_expire_modify(curr_expiration, 'MAX_DAYS')
969+
970+
# Aging policy for new users
971+
self.modify_single_file_inplace(ETC_LOGIN_DEF, ["\'/^PASS_MAX_DAYS/c\PASS_MAX_DAYS " +str(curr_expiration)+"\'"])
972+
973+
if self.is_passwd_aging_expire_update(curr_expiration_warning, 'WARN_DAYS'):
974+
# Aging policy for existing users
975+
self.passwd_aging_expire_modify(curr_expiration_warning, 'WARN_DAYS')
976+
977+
# Aging policy for new users
978+
self.modify_single_file_inplace(ETC_LOGIN_DEF, ["\'/^PASS_WARN_AGE/c\PASS_WARN_AGE " +str(curr_expiration_warning)+"\'"])
979+
980+
def passwd_aging_expire_modify(self, curr_expiration, age_type):
981+
normal_accounts = self.get_normal_accounts()
982+
if not normal_accounts:
983+
syslog.syslog(syslog.LOG_ERR,"failed, no normal users found in /etc/passwd")
984+
return
985+
chage_flag = AGE_DICT[age_type]['CHAGE_FLAG']
986+
for normal_account in normal_accounts:
987+
try:
988+
chage_p_m = subprocess.Popen(('chage', chage_flag + str(curr_expiration), normal_account), stdout=subprocess.PIPE)
989+
return_code_chage_p_m = chage_p_m.poll()
990+
if return_code_chage_p_m != 0:
991+
syslog.syslog(syslog.LOG_ERR, "failed: return code - {}".format(return_code_chage_p_m))
992+
993+
except subprocess.CalledProcessError as e:
994+
syslog.syslog(syslog.LOG_ERR, "{} - failed: return code - {}, output:\n{}".format(e.cmd, e.returncode, e.output))
995+
996+
def is_passwd_aging_expire_update(self, curr_expiration, age_type):
997+
""" Function verify that the current age expiry policy values are equal from the old one
998+
Return update_age_status 'True' value meaning that was a modification from the last time, and vice versa.
999+
"""
1000+
update_age_status = False
1001+
days_num = None
1002+
regex_days = AGE_DICT[age_type]['REGEX_DAYS']
1003+
days_type = AGE_DICT[age_type]['DAYS']
1004+
if os.path.exists(ETC_LOGIN_DEF):
1005+
with open(ETC_LOGIN_DEF, 'r') as f:
1006+
login_def_data = f.readlines()
1007+
1008+
for line in login_def_data:
1009+
m1 = re.match(regex_days, line)
1010+
if m1:
1011+
days_num = int(m1.group(days_type))
1012+
break
1013+
1014+
if curr_expiration != days_num:
1015+
update_age_status = True
1016+
1017+
return update_age_status
1018+
1019+
def get_normal_accounts(self):
1020+
# Get user list
1021+
try:
1022+
getent_out = subprocess.check_output(['getent', 'passwd']).decode('utf-8').split('\n')
1023+
except subprocess.CalledProcessError as err:
1024+
syslog.syslog(syslog.LOG_ERR, "{} - failed: return code - {}, output:\n{}".format(err.cmd, err.returncode, err.output))
1025+
return False
1026+
1027+
# Get range of normal users
1028+
REGEX_UID_MAX = r'^UID_MAX[ \t]*(?P<uid_max>\d*)'
1029+
REGEX_UID_MIN = r'^UID_MIN[ \t]*(?P<uid_min>\d*)'
1030+
uid_max = None
1031+
uid_min = None
1032+
if os.path.exists(ETC_LOGIN_DEF):
1033+
with open(ETC_LOGIN_DEF, 'r') as f:
1034+
login_def_data = f.readlines()
1035+
1036+
for line in login_def_data:
1037+
m1 = re.match(REGEX_UID_MAX, line)
1038+
m2 = re.match(REGEX_UID_MIN, line)
1039+
if m1:
1040+
uid_max = int(m1.group("uid_max"))
1041+
if m2:
1042+
uid_min = int(m2.group("uid_min"))
1043+
1044+
if not uid_max or not uid_min:
1045+
syslog.syslog(syslog.LOG_ERR,"failed, no UID_MAX/UID_MIN founded in login.def file")
1046+
return False
1047+
1048+
# Get normal user list
1049+
normal_accounts = []
1050+
for account in getent_out[0:-1]: # last item is always empty
1051+
account_spl = account.split(':')
1052+
account_number = int(account_spl[2])
1053+
if account_number >= uid_min and account_number <= uid_max:
1054+
normal_accounts.append(account_spl[ACCOUNT_NAME])
1055+
1056+
normal_accounts.append('root') # root is also a candidate to be age modify.
1057+
return normal_accounts
1058+
1059+
def modify_passw_conf_file(self):
1060+
passw_policies = self.passw_policies_default.copy()
1061+
passw_policies.update(self.passw_policies)
1062+
1063+
# set new Password Hardening policies.
1064+
self.set_passw_hardening_policies(passw_policies)
1065+
1066+
8701067
class KdumpCfg(object):
8711068
def __init__(self, CfgDb):
8721069
self.config_db = CfgDb
@@ -1090,6 +1287,9 @@ class HostConfigDaemon:
10901287
self.hostname_cache=""
10911288
self.aaacfg = AaaCfg()
10921289

1290+
# Initialize PasswHardening
1291+
self.passwcfg = PasswHardening()
1292+
10931293
# Initialize PamLimitsCfg
10941294
self.pamLimitsCfg = PamLimitsCfg(self.config_db)
10951295
self.pamLimitsCfg.update_config_file()
@@ -1105,12 +1305,14 @@ class HostConfigDaemon:
11051305
ntp_server = init_data['NTP_SERVER']
11061306
ntp_global = init_data['NTP']
11071307
kdump = init_data['KDUMP']
1308+
passwh = init_data['PASSW_HARDENING']
11081309

11091310
self.feature_handler.sync_state_field(features)
11101311
self.aaacfg.load(aaa, tacacs_global, tacacs_server, radius_global, radius_server)
11111312
self.iptables.load(lpbk_table)
11121313
self.ntpcfg.load(ntp_global, ntp_server)
11131314
self.kdumpCfg.load(kdump)
1315+
self.passwcfg.load(passwh)
11141316

11151317
dev_meta = self.config_db.get_table('DEVICE_METADATA')
11161318
if 'localhost' in dev_meta:
@@ -1131,6 +1333,10 @@ class HostConfigDaemon:
11311333
self.aaacfg.aaa_update(key, data)
11321334
syslog.syslog(syslog.LOG_INFO, 'AAA Update: key: {}, op: {}, data: {}'.format(key, op, data))
11331335

1336+
def passwh_handler(self, key, op, data):
1337+
self.passwcfg.passw_policies_update(key, data)
1338+
syslog.syslog(syslog.LOG_INFO, 'PASSW_HARDENING Update: key: {}, op: {}, data: {}'.format(key, op, data))
1339+
11341340
def tacacs_server_handler(self, key, op, data):
11351341
self.aaacfg.tacacs_server_update(key, data)
11361342
log_data = copy.deepcopy(data)
@@ -1229,6 +1435,7 @@ class HostConfigDaemon:
12291435
self.config_db.subscribe('TACPLUS_SERVER', make_callback(self.tacacs_server_handler))
12301436
self.config_db.subscribe('RADIUS', make_callback(self.radius_global_handler))
12311437
self.config_db.subscribe('RADIUS_SERVER', make_callback(self.radius_server_handler))
1438+
self.config_db.subscribe('PASSW_HARDENING', make_callback(self.passwh_handler))
12321439
# Handle IPTables configuration
12331440
self.config_db.subscribe('LOOPBACK_INTERFACE', make_callback(self.lpbk_handler))
12341441
# Handle NTP & NTP_SERVER updates

0 commit comments

Comments
 (0)