# -*- coding: utf-8 -*- from setuphelpers import * import os import re import tempfile import xml.etree.ElementTree as ET # --------------------------------------------------------------------------- # Identifiants de l'audit WAPT # --------------------------------------------------------------------------- AUDIT_SECTION = "audit-gpresult" AUDIT_NAME = "user-gpo" def install(): """ Ce paquet est uniquement destiné à l'audit. Aucune modification persistante n'est effectuée sur le poste. """ pass # --------------------------------------------------------------------------- # Fonctions utilitaires XML # --------------------------------------------------------------------------- def _clean_tag(tag): """ Supprime le namespace XML d'un tag ElementTree. """ return tag.split("}")[-1] def _text(element, tag, default=""): """ Retourne récursivement la première valeur XML correspondant au tag demandé, en ignorant les namespaces. """ if element is None: return default for child in element.iter(): if _clean_tag(child.tag).lower() == tag.lower(): if child.text: return child.text.strip() return default def _to_bool(value): """ Convertit une valeur booléenne textuelle issue de GPResult en booléen Python. """ return str(value).strip().lower() in ( "true", "1", "yes", "oui" ) def _split_account(account): """ Sépare un compte Windows entre son préfixe et son nom de compte. Exemples : FORMATION\\sebastien -> ("FORMATION", "sebastien") WSMANAGE30\\formation -> ("WSMANAGE30", "formation") """ if not account: return "", "" if "\\" in account: return account.split("\\", 1) return "", account # --------------------------------------------------------------------------- # Détection de l'utilisateur interactif # --------------------------------------------------------------------------- def _get_logged_on_user(): """ Retourne le nom de l'utilisateur associé à la session interactive active. Les audits WAPT s'exécutent dans le contexte du service WAPT. Il n'est donc pas possible de se fier simplement à l'identité du processus courant. `quser` est utilisé pour inspecter les sessions Windows et récupérer l'utilisateur de la session interactive active. Exemples retournés par cette fonction : formation sebastien L'identité complète du compte est ensuite résolue par GPResult : WSMANAGE30\\formation FORMATION\\sebastien Returns: str ou None """ try: output = run( r'quser.exe', timeout=30, return_stderr=False ) if not output: return None lines = output.splitlines() # La première ligne correspond aux en-têtes de colonnes. for line in lines[1:]: line = line.strip() if not line: continue # quser peut préfixer la session courante avec ">". line = line.lstrip(">").strip() # Prend en charge les sorties Windows en anglais et en français. if ( " Active " not in " %s " % line and " Actif " not in " %s " % line ): continue parts = re.split( r"\s+", line ) if not parts: continue # USERNAME correspond à la première colonne. username = parts[0].strip() if username: return username return None except Exception as e: print( "Unable to detect interactive user: %s" % e ) return None # --------------------------------------------------------------------------- # Recherche des données GPResult dans le XML # --------------------------------------------------------------------------- def _find_user_results(root): """ Recherche la section UserResults dans le document XML GPResult. """ for element in root.iter(): if _clean_tag(element.tag) == "UserResults": return element return None # --------------------------------------------------------------------------- # Analyse des GPO # --------------------------------------------------------------------------- def _get_gpos(scope_results): """ Extrait les noms des GPO appliquées et refusées depuis le RSoP USER. Les structures GPO internes sans nom sont ignorées et les doublons sont supprimés. """ applied = [] denied = [] seen = set() if scope_results is None: return applied, denied for element in scope_results.iter(): if _clean_tag(element.tag) != "GPO": continue name = _text( element, "Name" ) if not name: continue enabled = _to_bool( _text(element, "Enabled") ) valid = _to_bool( _text(element, "IsValid") ) filter_allowed = _to_bool( _text(element, "FilterAllowed") ) access_denied = _to_bool( _text(element, "AccessDenied") ) version_directory = _text( element, "VersionDirectory" ) version_sysvol = _text( element, "VersionSysvol" ) identity = ( name, version_directory, version_sysvol ) if identity in seen: continue seen.add(identity) if ( enabled and valid and filter_allowed and not access_denied ): applied.append(name) else: denied.append(name) return applied, denied # --------------------------------------------------------------------------- # Analyse des groupes de sécurité # --------------------------------------------------------------------------- def _get_security_groups(scope_results, principal_name): """ Extrait et classe les groupes de sécurité utilisés dans le RSoP USER. Catégories : domain: Groupes dont le préfixe correspond au préfixe de domaine du compte utilisateur. Exemple : FORMATION\\Domain Users local: Groupes BUILTIN ou groupes appartenant au poste local. Exemples : BUILTIN\\Users WSMANAGE30\\Administrators system: Identités NT AUTHORITY. Exemples : NT AUTHORITY\\Authenticated Users NT AUTHORITY\\INTERACTIVE other: Identités de sécurité qui ne peuvent pas être classées de manière fiable. Cette distinction évite notamment qu'un compte local tel que : WSMANAGE30\\formation soit considéré à tort comme un compte Active Directory. """ groups = { "domain": [], "local": [], "system": [], "other": [], } seen = set() if scope_results is None: return groups principal_domain, _ = _split_account( principal_name ) principal_domain = principal_domain.upper() computer_name = os.environ.get( "COMPUTERNAME", "" ).upper() # Le préfixe du compte n'est considéré comme un domaine AD # que s'il est différent du nom du poste local. ad_domain = "" if ( principal_domain and principal_domain != computer_name ): ad_domain = principal_domain for element in scope_results.iter(): if _clean_tag(element.tag) != "SecurityGroup": continue name = _text( element, "Name" ) if not name: continue name = name.strip() key = name.lower() if key in seen: continue seen.add(key) upper_name = name.upper() if upper_name.startswith( "NT AUTHORITY\\" ): groups["system"].append(name) elif upper_name.startswith( "BUILTIN\\" ): groups["local"].append(name) elif ( computer_name and upper_name.startswith( computer_name + "\\" ) ): groups["local"].append(name) elif ( ad_domain and upper_name.startswith( ad_domain + "\\" ) ): groups["domain"].append(name) else: groups["other"].append(name) # Garantit un ordre stable dans les résultats. for category in groups: groups[category] = sorted( groups[category], key=lambda x: x.lower() ) return groups # --------------------------------------------------------------------------- # Audit WAPT # --------------------------------------------------------------------------- def audit(): """ Exécute GPResult pour le contexte USER de l'utilisateur interactif actif. Déroulement : 1. Détecte la session utilisateur Windows active. 2. Exécute GPResult pour le RSoP USER de ce compte. 3. Analyse les GPO appliquées et refusées. 4. Extrait et classe les groupes de sécurité. 5. Publie le résultat structuré dans les données d'audit WAPT. L'absence d'utilisateur interactif est considérée comme une situation normale. Dans ce cas, l'audit WAPT retourne OK avec un statut fonctionnel NO_USER. """ xml_file = os.path.join( tempfile.gettempdir(), "wapt-gpresult-user.xml" ) try: # WAPT s'exécute en tant que service. # Il faut donc identifier explicitement l'utilisateur interactif. logged_on_user = _get_logged_on_user() if not logged_on_user: audit_data = { "status": "NO_USER", "message": "No active interactive user session", "user": None, "applied_gpos": [], "denied_gpos": [], "domain_groups": [], "security_groups": { "domain": [], "local": [], "system": [], "other": [], } } WAPT.write_audit_data_if_changed( AUDIT_SECTION, AUDIT_NAME, audit_data, keep_days=365, max_count=1 ) print( "No active interactive user session" ) # L'absence d'utilisateur connecté n'est pas considérée # comme un échec de l'audit. return "OK" print( "Active interactive user: %s" % logged_on_user ) # Supprime un éventuel rapport restant d'une exécution précédente. if os.path.isfile(xml_file): os.remove(xml_file) gpresult_path = makepath( system32(), "gpresult.exe" ) # Le nom utilisateur retourné par quser suffit à GPResult # pour résoudre le compte local ou de domaine réellement associé # au RSoP. command = ( '"%s" ' '/USER "%s" ' '/SCOPE USER ' '/X "%s" ' '/F' % ( gpresult_path, logged_on_user, xml_file ) ) print( "Running GPResult USER for %s..." % logged_on_user ) result = run( command, timeout=120, return_stderr=False ) if not os.path.isfile(xml_file): raise Exception( "GPResult did not generate XML report. " "Output: %s" % result ) tree = ET.parse( xml_file ) root = tree.getroot() user_results = _find_user_results( root ) if user_results is None: raise Exception( "UserResults section not found" ) applied_gpos, denied_gpos = _get_gpos( user_results ) # On laisse GPResult fournir l'identité de compte faisant foi. # # Exemples : # FORMATION\\sebastien # WSMANAGE30\\formation rsop_user = _text( user_results, "Name" ) if not rsop_user: rsop_user = logged_on_user account_domain, username = _split_account( rsop_user ) security_groups = _get_security_groups( user_results, rsop_user ) computer_name = os.environ.get( "COMPUTERNAME", "" ) # Détermine si GPResult a résolu le compte # comme compte local ou compte de domaine. if ( not account_domain or account_domain.upper() == computer_name.upper() ): account_type = "local" else: account_type = "domain" audit_data = { "status": "OK", "user": { # Identité complète du compte telle que résolue par GPResult. "account": rsop_user, "username": username, # Préfixe au format NetBIOS : # nom de domaine pour un compte AD, # nom du poste pour un compte local. "account_domain": account_domain, "account_type": account_type, }, "summary": { "applied_gpo_count": len( applied_gpos ), "denied_gpo_count": len( denied_gpos ), "total_gpo_count": ( len(applied_gpos) + len(denied_gpos) ), "domain_group_count": len( security_groups["domain"] ), "local_group_count": len( security_groups["local"] ), "system_group_count": len( security_groups["system"] ), "other_group_count": len( security_groups["other"] ), }, # Des listes simples sont volontairement conservées # afin de faciliter l'exploitation en SQL / reporting. "applied_gpos": applied_gpos, "denied_gpos": denied_gpos, # Liste pratique contenant uniquement les groupes AD, # généralement les plus intéressants dans un audit GPO. "domain_groups": security_groups["domain"], # Le contexte complet est conservé pour faciliter le diagnostic. "security_groups": security_groups, } WAPT.write_audit_data_if_changed( AUDIT_SECTION, AUDIT_NAME, audit_data, keep_days=365, max_count=1 ) print( "GPResult USER %s: " "%s applied / %s denied" % ( rsop_user, len(applied_gpos), len(denied_gpos) ) ) print( "Account type: %s" % account_type ) print( "AD groups: %s" % len( security_groups["domain"] ) ) return "OK" except Exception as e: # Conserve également l'erreur dans les données d'audit WAPT # afin qu'elle reste visible depuis la console. WAPT.write_audit_data_if_changed( AUDIT_SECTION, AUDIT_NAME, { "status": "ERROR", "message": str(e) }, keep_days=365, max_count=1 ) print( "GPResult USER audit error: %s" % str(e) ) return "ERROR" finally: # Le XML GPResult est uniquement utilisé comme fichier temporaire. try: if os.path.isfile(xml_file): os.remove(xml_file) except Exception: pass