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 set_password to bsd_shadow #59140

Merged
merged 7 commits into from Jan 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/59140.added
@@ -0,0 +1 @@
Added shadow.gen_password for BSD operating systems.
68 changes: 54 additions & 14 deletions salt/modules/bsd_shadow.py
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""
Manage the password database on BSD systems

Expand All @@ -9,21 +8,22 @@
<module-provider-override>`.
"""

# Import python libs
from __future__ import absolute_import, print_function, unicode_literals

import salt.utils.files
import salt.utils.stringutils
from salt.exceptions import SaltInvocationError

# Import salt libs
from salt.ext import six
from salt.exceptions import CommandExecutionError, SaltInvocationError

try:
import pwd
except ImportError:
pass

try:
import salt.utils.pycrypto

HAS_CRYPT = True
except ImportError:
HAS_CRYPT = False

# Define the module's virtual name
__virtualname__ = "shadow"
Expand Down Expand Up @@ -52,6 +52,46 @@ def default_hash():
return "*" if __grains__["os"].lower() == "freebsd" else "*************"


def gen_password(password, crypt_salt=None, algorithm="sha512"):
"""
Generate hashed password

.. note::

When called this function is called directly via remote-execution,
the password argument may be displayed in the system's process list.
This may be a security risk on certain systems.

password
Plaintext password to be hashed.

crypt_salt
Crpytographic salt. If not given, a random 8-character salt will be
generated.

algorithm
The following hash algorithms are supported:

* md5
* blowfish (not in mainline glibc, only available in distros that add it)
* sha256
* sha512 (default)

CLI Example:

.. code-block:: bash

salt '*' shadow.gen_password 'I_am_password'
salt '*' shadow.gen_password 'I_am_password' crypt_salt='I_am_salt' algorithm=sha256
"""
if not HAS_CRYPT:
raise CommandExecutionError(
"gen_password is not available on this operating system "
'because the "crypt" python module is not available.'
)
return salt.utils.pycrypto.gen_hash(crypt_salt, password, algorithm)


def info(name):
"""
Return information for the specified user
Expand All @@ -68,10 +108,10 @@ def info(name):
except KeyError:
return {"name": "", "passwd": ""}

if not isinstance(name, six.string_types):
name = six.text_type(name)
if not isinstance(name, str):
name = str(name)
if ":" in name:
raise SaltInvocationError("Invalid username '{0}'".format(name))
raise SaltInvocationError("Invalid username '{}'".format(name))

if __salt__["cmd.has_exec"]("pw"):
change, expire = __salt__["cmd.run_stdout"](
Expand All @@ -82,12 +122,12 @@ def info(name):
with salt.utils.files.fopen("/etc/master.passwd", "r") as fp_:
for line in fp_:
line = salt.utils.stringutils.to_unicode(line)
if line.startswith("{0}:".format(name)):
if line.startswith("{}:".format(name)):
key = line.split(":")
change, expire = key[5:7]
ret["passwd"] = six.text_type(key[1])
ret["passwd"] = str(key[1])
break
except IOError:
except OSError:
change = expire = None
else:
change = expire = None
Expand Down Expand Up @@ -171,7 +211,7 @@ def del_password(name):

salt '*' shadow.del_password username
"""
cmd = "pw user mod {0} -w none".format(name)
cmd = "pw user mod {} -w none".format(name)
__salt__["cmd.run"](cmd, python_shell=False, output_loglevel="quiet")
uinfo = info(name)
return not uinfo["passwd"]
Expand Down
118 changes: 118 additions & 0 deletions tests/unit/modules/test_bsd_shadow.py
@@ -0,0 +1,118 @@
"""
:codeauthor: Alan Somers <asomers@gmail.com>
"""


import re

import salt.utils.platform
from tests.support.mixins import LoaderModuleMockMixin
from tests.support.mock import MagicMock, patch
from tests.support.unit import TestCase, skipIf

try:
import salt.modules.bsd_shadow as shadow

HAS_SHADOW = True
except ImportError:
HAS_SHADOW = False

# Although bsd_shadow runs on NetBSD and OpenBSD as well, the mocks are
# currently only designed for FreeBSD.
@skipIf(not salt.utils.platform.is_freebsd(), "minion is not FreeBSD")
@skipIf(not HAS_SHADOW, "shadow module is not available")
class BSDShadowTest(TestCase, LoaderModuleMockMixin):
def setup_loader_modules(self):
return {
shadow: {
"__grains__": {"kernel": "FreeBSD", "os": "FreeBSD"},
"__salt__": {"cmd.has_exec": MagicMock(return_value=True)},
}
}

def test_del_password(self):
"""
Test shadow.del_password
"""
info_mock = MagicMock(return_value="root::0:0::0:0:Charlie &:/root:/bin/sh")
usermod_mock = MagicMock(return_value=0)
with patch.dict(shadow.__salt__, {"cmd.run_stdout": info_mock}):
with patch.dict(shadow.__salt__, {"cmd.run": usermod_mock}):
shadow.del_password("root")
usermod_mock.assert_called_once_with(
"pw user mod root -w none", output_loglevel="quiet", python_shell=False
)

def test_gen_password(self):
"""
Test shadow.gen_password
"""
self.assertEqual(
"$6$salt$wZU8LXJfJJqoagopbB7RuK6JEotEMZ0CQDy0phpPAuLMYQFcmf6L6BdAbs/Q7w7o1qsZ9pFqFVY4yuUSWgaYt1",
shadow.gen_password("x", crypt_salt="salt", algorithm="sha512"),
)
self.assertEqual(
"$5$salt$eC8iHMk0B/acxRGi4idWiCK/.xXHLUsxovn4V591t3.",
shadow.gen_password("x", crypt_salt="salt", algorithm="sha256"),
)

def test_info(self):
"""
Test shadow.info
"""
mock = MagicMock(return_value="root:*:0:0::42:69:Charlie &:/root:/bin/sh")
with patch.dict(shadow.__salt__, {"cmd.run_stdout": mock}):
info = shadow.info("root")
self.assertEqual("root", info["name"])
self.assertEqual(42, info["change"])
self.assertEqual(69, info["expire"])
self.assertTrue(
info["passwd"] == "*" # if the test is not running as root
or re.match(r"^\$[0-9]\$", info["passwd"]) # modular format
or re.match(r"^_", info["passwd"]) # DES Extended format
or info["passwd"] == "" # No password
or re.match(r"^\*LOCKED\*", info["passwd"]) # Locked account
)

def test_set_change(self):
"""
Test shadow.set_change
"""
info_mock = MagicMock(return_value="root:*:0:0::0:0:Charlie &:/root:/bin/sh")
usermod_mock = MagicMock(return_value=0)
with patch.dict(shadow.__salt__, {"cmd.run_stdout": info_mock}):
with patch.dict(shadow.__salt__, {"cmd.run": usermod_mock}):
shadow.set_change("root", 42)
usermod_mock.assert_called_once_with(
["pw", "user", "mod", "root", "-f", 42], python_shell=False
)

def test_set_expire(self):
"""
Test shadow.set_expire
"""
info_mock = MagicMock(return_value="root:*:0:0::0:0:Charlie &:/root:/bin/sh")
usermod_mock = MagicMock(return_value=0)
with patch.dict(shadow.__salt__, {"cmd.run_stdout": info_mock}):
with patch.dict(shadow.__salt__, {"cmd.run": usermod_mock}):
shadow.set_expire("root", 42)
usermod_mock.assert_called_once_with(
["pw", "user", "mod", "root", "-e", 42], python_shell=False
)

def test_set_password(self):
"""
Test shadow.set_password
"""
PASSWORD = "$6$1jReqE6eU.b.fl0X$lzsxgaP6kgPyW0kxeDhAn0ySH08gn5A3At0NDHRFUSkk/6s4hCgE9OTpSsNs1Vcvws3zN0lEXkxCYeZoTVY4A1"
info_mock = MagicMock(return_value="root:%s:0:0::0:0:Charlie &:/root:/bin/sh")
usermod_mock = MagicMock(return_value=0)
with patch.dict(shadow.__salt__, {"cmd.run_stdout": info_mock}):
with patch.dict(shadow.__salt__, {"cmd.run": usermod_mock}):
shadow.set_password("root", PASSWORD)
usermod_mock.assert_called_once_with(
["pw", "user", "mod", "root", "-H", "0"],
stdin=PASSWORD,
output_loglevel="quiet",
python_shell=False,
)