Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Password Hardening #10323

Merged
merged 25 commits into from
Jun 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2a59875
Password Hardening Feature
davidpil2002 Mar 1, 2022
440307d
fix password hardening comments from the pull request
davidpil2002 Mar 2, 2022
fb5764e
Merge branch 'master' into dev-password-hardening
davidpil2002 Mar 7, 2022
7a7fc90
small fix about passw hardening P.R
davidpil2002 Mar 8, 2022
9563886
move init_cfg.json.j2 changes to separate pull request with YANG model
davidpil2002 Mar 8, 2022
49d1195
modify age calculation & fix some values in the common-password.j2 file
davidpil2002 Mar 10, 2022
eff356a
fix digits class name case
davidpil2002 Mar 16, 2022
5ab7c2b
fix unitest hostcfgd_passwh_test.py by adding enable_digits_class sample
davidpil2002 Mar 24, 2022
a5959f5
Merge branch 'master' into dev-password-hardening
davidpil2002 Mar 24, 2022
a4d452b
add mock table to passw hardening unitest in result of changes in com…
davidpil2002 Mar 27, 2022
93072bd
fix credit disabled, by setting 0 instead to be clear
davidpil2002 Mar 28, 2022
3a8b5fd
removed unused import
davidpil2002 Mar 29, 2022
424bf9b
Merge branch 'master' into dev-password-hardening
davidpil2002 Apr 17, 2022
e9c9edf
passw-hardening, fix unitest mocks tables
davidpil2002 Apr 18, 2022
b611fa1
Merge branch 'master' into dev-password-hardening
davidpil2002 Apr 25, 2022
8e507e8
[passw-hardening] remove misstype line in hostcfgd
davidpil2002 Apr 27, 2022
e4bface
[password-hardening] move passw logic from AaaCfg class to PasswHarde…
davidpil2002 Apr 28, 2022
458691c
[password-hardening]fix few comments from PR: https://github.com/Azur…
davidpil2002 May 1, 2022
3b5f7a1
Merge branch 'Azure:master' into dev-password-hardening
davidpil2002 May 29, 2022
010e023
Merge branch 'Azure:master' into dev-password-hardening
davidpil2002 May 30, 2022
eb977ca
[ci] Publish logs when building image job is canceled by timeout. (#1…
liushilongbuaa May 30, 2022
0f423af
[CODEOWNERS]: update code owners for various repos (#10980)
lguohan May 30, 2022
8c4ef50
[Ci]: Fix the target directory not empty issue when publishing artifa…
xumia May 30, 2022
2967247
[password-hardening]install cracklib from debian repo list instead do…
davidpil2002 May 31, 2022
a2ec817
Merge branch 'master' into dev-password-hardening
davidpil2002 May 31, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions files/build_templates/sonic_debian_extension.j2
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@ fi
sudo dpkg --root=$FILESYSTEM_ROOT -i $debs_path/sonic-device-data_*.deb || \
sudo LANG=C DEBIAN_FRONTEND=noninteractive chroot $FILESYSTEM_ROOT apt-get -y install -f

# package for supporting password hardening
sudo LANG=C DEBIAN_FRONTEND=noninteractive chroot $FILESYSTEM_ROOT apt-get -y install libpam-cracklib

# Install pam-tacplus and nss-tacplus
sudo dpkg --root=$FILESYSTEM_ROOT -i $debs_path/libtac2_*.deb || \
sudo LANG=C DEBIAN_FRONTEND=noninteractive chroot $FILESYSTEM_ROOT apt-get -y install -f
Expand Down
43 changes: 43 additions & 0 deletions src/sonic-host-services-data/templates/common-password.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#THIS IS AN AUTO-GENERATED FILE
#
# /etc/pam.d/common-password - password-related modules common to all services
#
# This file is included from other service-specific PAM config files,
# and should contain a list of modules that define the services to be
# used to change user passwords. The default is pam_unix.

# Explanation of pam_unix options:
# The "yescrypt" option enables
#hashed passwords using the yescrypt algorithm, introduced in Debian
#11. Without this option, the default is Unix crypt. Prior releases
#used the option "sha512"; if a shadow password hash will be shared
#between Debian 11 and older releases replace "yescrypt" with "sha512"
#for compatibility . The "obscure" option replaces the old
#`OBSCURE_CHECKS_ENAB' option in login.defs. See the pam_unix manpage
#for other options.

# As of pam 1.0.1-6, this file is managed by pam-auth-update by default.
# To take advantage of this, it is recommended that you configure any
# local modules either before or after the default block, and use
# pam-auth-update to manage selection of other modules. See
# pam-auth-update(8) for details.

# here are the per-package modules (the "Primary" block)

{% if passw_policies %}
{% if passw_policies['state'] == 'enabled' %}
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

password required pam_pwhistory.so {% if passw_policies['history_cnt'] %}remember={{passw_policies['history_cnt']}}{% endif %} use_authtok enforce_for_root
{% endif %}
{% endif %}

password [success=1 default=ignore] pam_unix.so obscure yescrypt
liuh-80 marked this conversation as resolved.
Show resolved Hide resolved
# here's the fallback if no module succeeds
password requisite pam_deny.so
# prime the stack with a positive return value if there isn't one already;
# this avoids us returning an error just because nothing sets a success code
# since the modules above will each just jump around
password required pam_permit.so
# and here are more per-package modules (the "Additional" block)
# end of pam-auth-update config
211 changes: 209 additions & 2 deletions src/sonic-host-services/scripts/hostcfgd
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ import sys
import subprocess
import syslog
import signal

import re
import jinja2
from sonic_py_common import device_info
from swsscommon.swsscommon import ConfigDBConnector, DBConnector, Table

# FILE
PAM_AUTH_CONF = "/etc/pam.d/common-auth-sonic"
PAM_AUTH_CONF_TEMPLATE = "/usr/share/sonic/templates/common-auth-sonic.j2"
PAM_PASSWORD_CONF = "/etc/pam.d/common-password"
PAM_PASSWORD_CONF_TEMPLATE = "/usr/share/sonic/templates/common-password.j2"
NSS_TACPLUS_CONF = "/etc/tacplus_nss.conf"
NSS_TACPLUS_CONF_TEMPLATE = "/usr/share/sonic/templates/tacplus_nss.conf.j2"
NSS_RADIUS_CONF = "/etc/radius_nss.conf"
Expand All @@ -24,6 +26,16 @@ PAM_RADIUS_AUTH_CONF_TEMPLATE = "/usr/share/sonic/templates/pam_radius_auth.conf
NSS_CONF = "/etc/nsswitch.conf"
ETC_PAMD_SSHD = "/etc/pam.d/sshd"
ETC_PAMD_LOGIN = "/etc/pam.d/login"
ETC_LOGIN_DEF = "/etc/login.defs"

# Linux login.def default values (password hardening disable)
LINUX_DEFAULT_PASS_MAX_DAYS = 99999
LINUX_DEFAULT_PASS_WARN_AGE = 7

ACCOUNT_NAME = 0 # index of account name
AGE_DICT = { 'MAX_DAYS': {'REGEX_DAYS': r'^PASS_MAX_DAYS[ \t]*(?P<max_days>\d*)', 'DAYS': 'max_days', 'CHAGE_FLAG': '-M '},
'WARN_DAYS': {'REGEX_DAYS': r'^PASS_WARN_AGE[ \t]*(?P<warn_days>\d*)', 'DAYS': 'warn_days', 'CHAGE_FLAG': '-W '}
}
PAM_LIMITS_CONF_TEMPLATE = "/usr/share/sonic/templates/pam_limits.j2"
LIMITS_CONF_TEMPLATE = "/usr/share/sonic/templates/limits.conf.j2"
PAM_LIMITS_CONF = "/etc/pam.d/pam-limits-conf"
Expand Down Expand Up @@ -85,8 +97,10 @@ def run_cmd(cmd, log_err=True, raise_exception=False):
def is_true(val):
if val == 'True' or val == 'true':
return True
else:
elif val == 'False' or val == 'false':
return False
syslog.syslog(syslog.LOG_ERR, "Failed to get bool value, instead val= {}".format(val))
return False


def is_vlan_sub_interface(ifname):
Expand Down Expand Up @@ -857,6 +871,189 @@ class AaaCfg(object):
.format(err.cmd, err.returncode, err.output))


class PasswHardening(object):
def __init__(self):
self.passw_policies_default = {}
self.passw_policies = {}

self.debug = False
self.trace = False

def load(self, policies_conf):
for row in policies_conf:
self.passw_policies_update(row, policies_conf[row], modify_conf=False)

self.modify_passw_conf_file()

def passw_policies_update(self, key, data, modify_conf=True):
syslog.syslog(syslog.LOG_DEBUG, "passw_policies_update - key: {}".format(key))
syslog.syslog(syslog.LOG_DEBUG, "passw_policies_update - data: {}".format(data))

if data == {}:
self.passw_policies = {}
else:
if 'reject_user_passw_match' in data:
data['reject_user_passw_match'] = is_true(data['reject_user_passw_match'])
if 'lower_class' in data:
data['lower_class'] = is_true(data['lower_class'])
if 'upper_class' in data:
data['upper_class'] = is_true(data['upper_class'])
if 'digits_class' in data:
data['digits_class'] = is_true(data['digits_class'])
if 'special_class' in data:
data['special_class'] = is_true(data['special_class'])

if key == 'POLICIES':
self.passw_policies = data

if modify_conf:
self.modify_passw_conf_file()

def modify_single_file_inplace(self, filename, operations=None):
if operations:
cmd = "sed -i {0} {1}".format(' -i '.join(operations), filename)
syslog.syslog(syslog.LOG_DEBUG, "modify_single_file_inplace: cmd - {}".format(cmd))
os.system(cmd)

def set_passw_hardening_policies(self, passw_policies):
# Password Hardening flow
# 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.
# When the feature is disabled, the files above will be generate with the linux default (without secured passw_policies).
syslog.syslog(syslog.LOG_DEBUG, "modify_conf_file: passw_policies - {}".format(passw_policies))

template_passwh_file = os.path.abspath(PAM_PASSWORD_CONF_TEMPLATE)
env = jinja2.Environment(loader=jinja2.FileSystemLoader('/'), trim_blocks=True)
env.filters['sub'] = sub
template_passwh = env.get_template(template_passwh_file)

# Render common-password file with passw hardening policies if any. Other render without them.
pam_passwh_conf = template_passwh.render(debug=self.debug, passw_policies=passw_policies)

# Use rename(), which is atomic (on the same fs) to avoid empty file
with open(PAM_PASSWORD_CONF + ".tmp", 'w') as f:
f.write(pam_passwh_conf)
os.chmod(PAM_PASSWORD_CONF + ".tmp", 0o644)
os.rename(PAM_PASSWORD_CONF + ".tmp", PAM_PASSWORD_CONF)

# Age policy
# When feature disabled or age policy disabled, expiry days policy should be as linux default, other, accoriding CONFIG_DB.
curr_expiration = LINUX_DEFAULT_PASS_MAX_DAYS
curr_expiration_warning = LINUX_DEFAULT_PASS_WARN_AGE

if passw_policies:
if 'state' in passw_policies:
if passw_policies['state'] == 'enabled':
if 'expiration' in passw_policies:
if int(self.passw_policies['expiration']) != 0: # value '0' meaning age policy is disabled
# the logic is to modify the expiration time according the last updated modificatiion
#
curr_expiration = int(passw_policies['expiration'])

if 'expiration_warning' in passw_policies:
if int(self.passw_policies['expiration_warning']) != 0: # value '0' meaning age policy is disabled
curr_expiration_warning = int(passw_policies['expiration_warning'])

if self.is_passwd_aging_expire_update(curr_expiration, 'MAX_DAYS'):
# Set aging policy for existing users
self.passwd_aging_expire_modify(curr_expiration, 'MAX_DAYS')

# Aging policy for new users
self.modify_single_file_inplace(ETC_LOGIN_DEF, ["\'/^PASS_MAX_DAYS/c\PASS_MAX_DAYS " +str(curr_expiration)+"\'"])

if self.is_passwd_aging_expire_update(curr_expiration_warning, 'WARN_DAYS'):
# Aging policy for existing users
self.passwd_aging_expire_modify(curr_expiration_warning, 'WARN_DAYS')

# Aging policy for new users
self.modify_single_file_inplace(ETC_LOGIN_DEF, ["\'/^PASS_WARN_AGE/c\PASS_WARN_AGE " +str(curr_expiration_warning)+"\'"])

def passwd_aging_expire_modify(self, curr_expiration, age_type):
normal_accounts = self.get_normal_accounts()
if not normal_accounts:
syslog.syslog(syslog.LOG_ERR,"failed, no normal users found in /etc/passwd")
return
chage_flag = AGE_DICT[age_type]['CHAGE_FLAG']
for normal_account in normal_accounts:
try:
chage_p_m = subprocess.Popen(('chage', chage_flag + str(curr_expiration), normal_account), stdout=subprocess.PIPE)
return_code_chage_p_m = chage_p_m.poll()
if return_code_chage_p_m != 0:
syslog.syslog(syslog.LOG_ERR, "failed: return code - {}".format(return_code_chage_p_m))

except subprocess.CalledProcessError as e:
syslog.syslog(syslog.LOG_ERR, "{} - failed: return code - {}, output:\n{}".format(e.cmd, e.returncode, e.output))

def is_passwd_aging_expire_update(self, curr_expiration, age_type):
""" Function verify that the current age expiry policy values are equal from the old one
Return update_age_status 'True' value meaning that was a modification from the last time, and vice versa.
"""
update_age_status = False
days_num = None
regex_days = AGE_DICT[age_type]['REGEX_DAYS']
days_type = AGE_DICT[age_type]['DAYS']
if os.path.exists(ETC_LOGIN_DEF):
with open(ETC_LOGIN_DEF, 'r') as f:
login_def_data = f.readlines()

for line in login_def_data:
m1 = re.match(regex_days, line)
if m1:
days_num = int(m1.group(days_type))
break

if curr_expiration != days_num:
update_age_status = True

return update_age_status

def get_normal_accounts(self):
# Get user list
try:
getent_out = subprocess.check_output(['getent', 'passwd']).decode('utf-8').split('\n')
except subprocess.CalledProcessError as err:
syslog.syslog(syslog.LOG_ERR, "{} - failed: return code - {}, output:\n{}".format(err.cmd, err.returncode, err.output))
return False

# Get range of normal users
REGEX_UID_MAX = r'^UID_MAX[ \t]*(?P<uid_max>\d*)'
REGEX_UID_MIN = r'^UID_MIN[ \t]*(?P<uid_min>\d*)'
uid_max = None
uid_min = None
if os.path.exists(ETC_LOGIN_DEF):
with open(ETC_LOGIN_DEF, 'r') as f:
login_def_data = f.readlines()

for line in login_def_data:
m1 = re.match(REGEX_UID_MAX, line)
m2 = re.match(REGEX_UID_MIN, line)
if m1:
uid_max = int(m1.group("uid_max"))
if m2:
uid_min = int(m2.group("uid_min"))

if not uid_max or not uid_min:
syslog.syslog(syslog.LOG_ERR,"failed, no UID_MAX/UID_MIN founded in login.def file")
return False

# Get normal user list
normal_accounts = []
for account in getent_out[0:-1]: # last item is always empty
liuh-80 marked this conversation as resolved.
Show resolved Hide resolved
account_spl = account.split(':')
account_number = int(account_spl[2])
if account_number >= uid_min and account_number <= uid_max:
normal_accounts.append(account_spl[ACCOUNT_NAME])

normal_accounts.append('root') # root is also a candidate to be age modify.
return normal_accounts

def modify_passw_conf_file(self):
passw_policies = self.passw_policies_default.copy()
passw_policies.update(self.passw_policies)

# set new Password Hardening policies.
self.set_passw_hardening_policies(passw_policies)


class KdumpCfg(object):
def __init__(self, CfgDb):
self.config_db = CfgDb
Expand Down Expand Up @@ -1080,6 +1277,9 @@ class HostConfigDaemon:
self.hostname_cache=""
self.aaacfg = AaaCfg()

# Initialize PasswHardening
self.passwcfg = PasswHardening()
liuh-80 marked this conversation as resolved.
Show resolved Hide resolved

# Initialize PamLimitsCfg
self.pamLimitsCfg = PamLimitsCfg(self.config_db)
self.pamLimitsCfg.update_config_file()
Expand All @@ -1095,12 +1295,14 @@ class HostConfigDaemon:
ntp_server = init_data['NTP_SERVER']
ntp_global = init_data['NTP']
kdump = init_data['KDUMP']
passwh = init_data['PASSW_HARDENING']

self.feature_handler.sync_state_field(features)
self.aaacfg.load(aaa, tacacs_global, tacacs_server, radius_global, radius_server)
self.iptables.load(lpbk_table)
self.ntpcfg.load(ntp_global, ntp_server)
self.kdumpCfg.load(kdump)
self.passwcfg.load(passwh)

dev_meta = self.config_db.get_table('DEVICE_METADATA')
if 'localhost' in dev_meta:
Expand All @@ -1121,6 +1323,10 @@ class HostConfigDaemon:
self.aaacfg.aaa_update(key, data)
syslog.syslog(syslog.LOG_INFO, 'AAA Update: key: {}, op: {}, data: {}'.format(key, op, data))

def passwh_handler(self, key, op, data):
self.passwcfg.passw_policies_update(key, data)
syslog.syslog(syslog.LOG_INFO, 'PASSW_HARDENING Update: key: {}, op: {}, data: {}'.format(key, op, data))

def tacacs_server_handler(self, key, op, data):
self.aaacfg.tacacs_server_update(key, data)
log_data = copy.deepcopy(data)
Expand Down Expand Up @@ -1219,6 +1425,7 @@ class HostConfigDaemon:
self.config_db.subscribe('TACPLUS_SERVER', make_callback(self.tacacs_server_handler))
self.config_db.subscribe('RADIUS', make_callback(self.radius_global_handler))
self.config_db.subscribe('RADIUS_SERVER', make_callback(self.radius_server_handler))
self.config_db.subscribe('PASSW_HARDENING', make_callback(self.passwh_handler))
# Handle IPTables configuration
self.config_db.subscribe('LOOPBACK_INTERFACE', make_callback(self.lpbk_handler))
# Handle NTP & NTP_SERVER updates
Expand Down
Loading