diff --git a/lib/ansible/plugins/filter/network.py b/lib/ansible/plugins/filter/network.py index 2a794a73360..bdfb6117740 100644 --- a/lib/ansible/plugins/filter/network.py +++ b/lib/ansible/plugins/filter/network.py @@ -23,13 +23,17 @@ __metaclass__ = type import re import os import traceback +import string from collections import Mapping from xml.etree.ElementTree import fromstring from ansible.module_utils.network.common.utils import Template from ansible.module_utils.six import iteritems, string_types -from ansible.errors import AnsibleError +from ansible.errors import AnsibleError, AnsibleFilterError +from ansible.utils.encrypt import random_password +from ansible.plugins.lookup import password as ansible_password + try: import yaml @@ -50,6 +54,12 @@ except ImportError: from ansible.utils.display import Display display = Display() +try: + from passlib.hash import md5_crypt + HAS_PASSLIB = True +except ImportError: + HAS_PASSLIB = False + def re_matchall(regex, value): objects = list() @@ -345,13 +355,56 @@ def parse_xml(output, tmpl): return obj +def type5_pw(password, salt=None): + if not HAS_PASSLIB: + raise AnsibleFilterError('type5_pw filter requires PassLib library to be installed') + + if not isinstance(password, string_types): + raise AnsibleFilterError("type5_pw password input should be a string, but was given a input of %s" % (type(password).__name__)) + + salt_chars = ansible_password._gen_candidate_chars(['ascii_letters', 'digits', './']) + if salt is not None and not isinstance(salt, string_types): + raise AnsibleFilterError("type5_pw salt input should be a string, but was given a input of %s" % (type(salt).__name__)) + elif not salt: + salt = random_password(length=4, chars=salt_chars) + elif not set(salt) <= set(salt_chars): + raise AnsibleFilterError("type5_pw salt used inproper characters, must be one of %s" % (salt_chars)) + + encrypted_password = md5_crypt.encrypt(password, salt=salt) + + return encrypted_password + + +def hash_salt(password): + + split_password = password.split("$") + if len(split_password) != 4: + raise AnsibleFilterError('Could not parse salt out password correctly from {0}'.format(password)) + else: + return split_password[2] + + +def comp_type5(unencrypted_password, encrypted_password, return_orginal=False): + + salt = hash_salt(encrypted_password) + if type5_pw(unencrypted_password, salt) == encrypted_password: + if return_orginal is True: + return encrypted_password + else: + return True + return False + + class FilterModule(object): """Filters for working with output from network devices""" filter_map = { 'parse_cli': parse_cli, 'parse_cli_textfsm': parse_cli_textfsm, - 'parse_xml': parse_xml + 'parse_xml': parse_xml, + 'type5_pw': type5_pw, + 'hash_salt': hash_salt, + 'comp_type5': comp_type5 } def filters(self): diff --git a/test/units/plugins/filter/test_network.py b/test/units/plugins/filter/test_network.py index 1836bd30ab1..71073d7e97a 100644 --- a/test/units/plugins/filter/test_network.py +++ b/test/units/plugins/filter/test_network.py @@ -20,8 +20,11 @@ __metaclass__ = type import os import sys +import pytest + from ansible.compat.tests import unittest -from ansible.plugins.filter.network import parse_xml +from ansible.plugins.filter.network import parse_xml, type5_pw, hash_salt, comp_type5 +from ansible.errors import AnsibleFilterError fixture_path = os.path.join(os.path.dirname(__file__), 'fixtures', 'network') @@ -78,3 +81,87 @@ class TestNetworkParseFilter(unittest.TestCase): with self.assertRaises(Exception) as e: parse_xml(output, spec_file_path) self.assertEqual("parse_xml works on string input, but given input of : %s" % type(output), str(e.exception)) + + +class TestNetworkType5(unittest.TestCase): + + def test_defined_salt_success(self): + password = 'cisco' + salt = 'nTc1' + expected = '$1$nTc1$Z28sUTcWfXlvVe2x.3XAa.' + parsed = type5_pw(password, salt) + self.assertEqual(parsed, expected) + + def test_undefined_salt_success(self): + password = 'cisco' + parsed = type5_pw(password) + self.assertEqual(len(parsed), 30) + + def test_wrong_data_type(self): + + with self.assertRaises(Exception) as e: + type5_pw([]) + self.assertEqual("type5_pw password input should be a string, but was given a input of list", str(e.exception)) + + with self.assertRaises(Exception) as e: + type5_pw({}) + self.assertEqual("type5_pw password input should be a string, but was given a input of dict", str(e.exception)) + + with self.assertRaises(Exception) as e: + type5_pw('pass', []) + self.assertEqual("type5_pw salt input should be a string, but was given a input of list", str(e.exception)) + + with self.assertRaises(Exception) as e: + type5_pw('pass', {}) + self.assertEqual("type5_pw salt input should be a string, but was given a input of dict", str(e.exception)) + + def test_bad_salt_char(self): + + with self.assertRaises(Exception) as e: + type5_pw('password', '*()') + self.assertEqual("type5_pw salt used inproper characters, must be one of " + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./", str(e.exception)) + + with self.assertRaises(Exception) as e: + type5_pw('password', 'asd$') + self.assertEqual("type5_pw salt used inproper characters, must be one of " + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./", str(e.exception)) + + +class TestHashSalt(unittest.TestCase): + + def test_retrieve_salt(self): + password = '$1$nTc1$Z28sUTcWfXlvVe2x.3XAa.' + parsed = hash_salt(password) + self.assertEqual(parsed, 'nTc1') + + password = '$2y$14$wHhBmAgOMZEld9iJtV.' + parsed = hash_salt(password) + self.assertEqual(parsed, '14') + + def test_unparseable_salt(self): + password = '$nTc1$Z28sUTcWfXlvVe2x.3XAa.' + with self.assertRaises(Exception) as e: + parsed = hash_salt(password) + self.assertEqual("Could not parse salt out password correctly from $nTc1$Z28sUTcWfXlvVe2x.3XAa.", str(e.exception)) + + +class TestCompareType5(unittest.TestCase): + + def test_compare_type5_boolean(self): + unencrypted_password = 'cisco' + encrypted_password = '$1$nTc1$Z28sUTcWfXlvVe2x.3XAa.' + parsed = comp_type5(unencrypted_password, encrypted_password) + self.assertEqual(parsed, True) + + def test_compare_type5_string(self): + unencrypted_password = 'cisco' + encrypted_password = '$1$nTc1$Z28sUTcWfXlvVe2x.3XAa.' + parsed = comp_type5(unencrypted_password, encrypted_password, True) + self.assertEqual(parsed, '$1$nTc1$Z28sUTcWfXlvVe2x.3XAa.') + + def test_compate_type5_fail(self): + unencrypted_password = 'invalid_password' + encrypted_password = '$1$nTc1$Z28sUTcWfXlvVe2x.3XAa.' + parsed = comp_type5(unencrypted_password, encrypted_password) + self.assertEqual(parsed, False)