From 247b93f4861b027673f87e0395435c1a3ffba29f Mon Sep 17 00:00:00 2001 From: Nico Ohnezat Date: Thu, 29 Feb 2024 00:01:18 +0100 Subject: [PATCH] first draft of LAPS lookup plugin --- plugins/lookup/__init__.py | 0 plugins/lookup/laps.py | 193 +++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 plugins/lookup/__init__.py create mode 100644 plugins/lookup/laps.py diff --git a/plugins/lookup/__init__.py b/plugins/lookup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/lookup/laps.py b/plugins/lookup/laps.py new file mode 100644 index 0000000..900535b --- /dev/null +++ b/plugins/lookup/laps.py @@ -0,0 +1,193 @@ +# Copyright: (c) 2024, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +DOCUMENTATION = r""" +name: laps +author: Nico Ohnezat (@no-12) +short_description: Inventory plugin for Active Directory +version_added: 2.2.0 +description: +- Lookup plugin that retrieves the LAPS password information for multiple hosts from an Active Directory server. +options: + _terms: + description: One or multiple C(dNSHostName) of the computer objects to search for. + required: True + laps: + description: + - The LAPS password type to retrieve. + - Defaults to the C(auto). This will attempt to retrieve the LAPS password in the following order: C(windows_encrypted), C(windows_plain_text), C(legacy_microsoft). + type: str + choices: + - auto + - windows_encrypted + - windows_plain_text + - legacy_microsoft + default: auto + search_base: + description: + - The LDAP search base to find the computer objects in. + - Defaults to the C(defaultNamingContext) of the Active Directory server + if not specified. + - If searching a larger Active Directory database, it is recommended to + narrow the search base to speed up the queries. + type: str + search_scope: + description: + - The scope of the LDAP search to perform. + - C(base) will search only the current path or object specified by + I(search_base). This is typically not useful for inventory plugins. + - C(one_level) will search only the immediate child objects in + I(search_base). + - C(subtree) will search the immediate child objects and any nested + objects in I(search_base). + choices: + - base + - one_level + - subtree + default: subtree + type: str +notes: +- This plugin is a tech preview and the module options are subject to change + based on feedback received. +extends_documentation_fragment: +- microsoft.ad.ldap_connection +""" + +import json + +from ansible.errors import AnsibleError +from ansible.module_utils.basic import missing_required_lib +from ansible.plugins.lookup import LookupBase + +try: + import sansldap + + from ..plugin_utils._ldap import create_ldap_connection + from ..plugin_utils._ldap.schema import LDAPSchema + from ..plugin_utils._ldap.laps import LAPSDecryptor + from ..filter.ldap_converters import as_datetime + + HAS_LDAP = True + LDAP_IMP_ERR = None +except Exception as e: + HAS_LDAP = False + LDAP_IMP_ERR = e + + +class LookupModule(LookupBase): + NAME = "microsoft.ad.laps" + + def _parse_value(self, parser, values): + if values: + return parser(values[0]) + return None + + def run(self, terms, variables=None, **kwargs): + self.set_options(var_options=variables, direct=kwargs) + + if not HAS_LDAP: + msg = missing_required_lib( + "sansldap and pyspnego", + url="https://pypi.org/project/sansldap/ and https://pypi.org/project/pyspnego/", + reason="for ldap lookups", + ) + raise AnsibleError(f"{msg}: {LDAP_IMP_ERR}") from LDAP_IMP_ERR + + laps = self.get_option("laps") + search_base = self.get_option("search_base") + search_scope = self.get_option("search_scope") + ldap_search_scope = { + "base": sansldap.SearchScope.BASE, + "one_level": sansldap.SearchScope.ONE_LEVEL, + "subtree": sansldap.SearchScope.SUBTREE, + }[search_scope] + + computer_filter = sansldap.FilterEquality("objectClass", b"computer") + dnshostname_filter = sansldap.FilterOr( + filters=[ + sansldap.FilterEquality("dnshostname", bytes(t, "utf-8")) for t in terms + ] + ) + final_filter = sansldap.FilterAnd(filters=[computer_filter, dnshostname_filter]) + + attributes = { + "dnshostname", + "mslaps-encryptedpassword", + "mslaps-password", + "mslaps-passwordexpirationtime", + "ms-mcs-admpwd", + "ms-mcs-admpwdexpirationtime", + } + + connection_options = self.get_options() + laps_decryptor = LAPSDecryptor(**connection_options) + return_value = [] + with create_ldap_connection(**connection_options) as client: + for _, info in client.search( + filter=final_filter, + attributes=list(attributes), + search_base=search_base, + search_scope=ldap_search_scope, + ).items(): + insensitive_info = {k.lower(): v for k, v in info.items()} + + hostname = self._parse_value( + bytes.decode, insensitive_info.get("dnshostname") + ) + + result = { + "hostname": hostname, + "laps": None, + } + + if laps in ["auto", "windows_encrypted"]: + raw_mslaps_encrypted_password = self._parse_value( + laps_decryptor.decrypt, + insensitive_info.get("mslaps-encryptedpassword"), + ) + if raw_mslaps_encrypted_password: + result["laps"] = "windows_encrypted" + parsed_mslaps_password = json.loads(raw_mslaps_password) + result["laps_username"] = parsed_mslaps_password.get("n") + result["laps_password"] = parsed_mslaps_password.get("p") + result["laps_password_expiration_time"] = self._parse_value( + as_datetime, + insensitive_info.get("mslaps-passwordexpirationtime"), + ) + return_value.append(result) + continue + + if laps in ["auto", "windows_plain_text"]: + raw_mslaps_password = self._parse_value( + bytes.decode, insensitive_info.get("mslaps-password") + ) + if raw_mslaps_password: + result["laps"] = "windows_plain_text" + parsed_mslaps_password = json.loads(raw_mslaps_password) + result["laps_username"] = parsed_mslaps_password.get("n") + result["laps_password"] = parsed_mslaps_password.get("p") + result["laps_password_expiration_time"] = self._parse_value( + as_datetime, + insensitive_info.get("mslaps-passwordexpirationtime"), + ) + return_value.append(result) + continue + + if laps in ["auto", "legacy_microsoft"]: + legacy_laps_password = self._parse_value( + bytes.decode, insensitive_info.get("ms-mcs-admpwd") + ) + if legacy_laps_password: + result["laps"] = "legacy_microsoft" + result["laps_username"] = "Administrator" + result["laps_password"] = legacy_laps_password + result["laps_password_expiration_time"] = self._parse_value( + as_datetime, + insensitive_info.get("ms-mcs-admpwdexpirationtime"), + ) + return_value.append(result) + continue + + return_value.append(result) + + return return_value