diff --git a/setup.py b/setup.py index 0e7d804..869a955 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,4 @@ # -*- coding: utf-8 -*- -"""Audit GPResult machine : resultat structure dans audit-gpresult.computer-gpo. - -L'installation est vide. La collecte, l'analyse XML et l'enregistrement -des donnees sont realises uniquement lors de l'execution de audit(). -""" from setuphelpers import * @@ -12,32 +7,63 @@ import tempfile import xml.etree.ElementTree as ET -# Categorie et cle des donnees d'audit WAPT. +# --------------------------------------------------------------------------- +# Identifiants de l'audit WAPT +# --------------------------------------------------------------------------- + AUDIT_SECTION = "audit-gpresult" AUDIT_NAME = "computer-gpo" def install(): - """Point d'entree d'installation ; aucune configuration a appliquer.""" + """ + Ce paquet est uniquement destiné à l'audit. + + Aucune action d'installation n'est nécessaire sur le poste. + """ pass +# --------------------------------------------------------------------------- +# Fonctions utilitaires XML +# --------------------------------------------------------------------------- + def _clean_tag(tag): - """Retire l'espace de noms XML eventuel d'une balise.""" + """ + Supprime le namespace XML d'un tag. + + Exemple : + "{http://www.microsoft.com/GroupPolicy/Rsop}Name" + devient : + "Name" + + Les fichiers XML générés par GPResult utilisent des namespaces. + Les supprimer permet de rendre le parseur indépendant du namespace + utilisé par Windows. + """ return tag.split("}")[-1] def _text(element, tag, default=""): - """Lit le premier texte non vide d'une balise dans tout le sous-arbre. + """ + Retourne le contenu texte du premier élément XML correspondant. - La recherche est recursive et insensible a la casse. Si aucune valeur - n'est trouvee, retourne la valeur par defaut. + La recherche est récursive et ignore les namespaces XML. + + Args: + element: élément XML à partir duquel effectuer la recherche. + tag: nom du tag recherché. + default: valeur retournée si le tag n'est pas trouvé. + + Returns: + str """ 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() @@ -45,7 +71,10 @@ def _text(element, tag, default=""): def _to_bool(value): - """Convertit les valeurs reconnues en True ; toute autre valeur est False.""" + """ + Convertit les représentations textuelles courantes d'un booléen + en booléen Python. + """ return str(value).strip().lower() in ( "true", "1", @@ -54,37 +83,94 @@ def _to_bool(value): ) +def _split_account(account): + """ + Sépare un compte Windows entre son préfixe et son nom de compte. + + Exemples : + "FORMATION\\WSMANAGE30$" + -> ("FORMATION", "WSMANAGE30$") + + "WSMANAGE30\\formation" + -> ("WSMANAGE30", "formation") + + Si aucun préfixe n'est présent : + "formation" + -> ("", "formation") + """ + + if not account: + return "", "" + + if "\\" in account: + return account.split("\\", 1) + + return "", account + + +# --------------------------------------------------------------------------- +# Recherche des données GPResult dans le XML +# --------------------------------------------------------------------------- + def _find_computer_results(root): - """Recherche la section machine sans dependre des espaces de noms XML.""" + """ + Recherche la section ComputerResults dans le document XML GPResult. + + Les informations RSoP du contexte COMPUTER sont stockées sous ce nœud. + """ + for element in root.iter(): + if _clean_tag(element.tag) == "ComputerResults": return element return None -def _get_gpos(computer_results): - """Classe les GPO machine en appliquees et non appliquees. +# --------------------------------------------------------------------------- +# Analyse des GPO +# --------------------------------------------------------------------------- - Les doublons sont elimines selon le triplet nom/version annuaire/ - version SYSVOL. Les GPO sans nom sont ignorees. +def _get_gpos(scope_results): """ + Extrait les GPO appliquées et refusées depuis un contexte GPResult. + + Le XML GPResult peut contenir plusieurs nœuds nommés "GPO", y compris + des structures internes ou vides. Les entrées GPO sans nom sont ignorées. + + Une GPO est considérée comme appliquée lorsque : + - elle est activée ; + - elle est valide ; + - le filtrage de sécurité / WMI l'autorise ; + - l'accès n'est pas refusé. + + Returns: + tuple: + applied_gpos: list[str] + denied_gpos: list[str] + """ + applied = [] denied = [] + # Permet d'éviter les doublons de GPO présents dans le XML GPResult. seen = set() - if computer_results is None: + if scope_results is None: return applied, denied - # Conserver la recherche recursive de la version de reference. - for element in computer_results.iter(): + for element in scope_results.iter(): if _clean_tag(element.tag) != "GPO": continue - name = _text(element, "Name") + name = _text( + element, + "Name" + ) + # GPResult peut exposer des structures GPO internes sans nom. + # Elles ne doivent pas apparaître dans les données d'audit WAPT. if not name: continue @@ -114,6 +200,8 @@ def _get_gpos(computer_results): "VersionSysvol" ) + # Le nom seul ne suffit pas toujours pour dédupliquer une GPO. + # Les versions AD et SYSVOL sont donc intégrées à son identité. identity = ( name, version_directory, @@ -125,9 +213,6 @@ def _get_gpos(computer_results): seen.add(identity) - # Une GPO est appliquee uniquement si les trois indicateurs - # positifs sont vrais et si aucun refus d'acces n'est indique. - # denied regroupe toutes les autres entrees retenues. if ( enabled and valid @@ -142,10 +227,191 @@ def _get_gpos(computer_results): return applied, denied -def audit(): - """Exporte le GPResult machine, enregistre les donnees et retourne un statut.""" +# --------------------------------------------------------------------------- +# Analyse des groupes de sécurité +# --------------------------------------------------------------------------- + +def _get_security_groups(scope_results, principal_name): + """ + Extrait et classe les groupes de sécurité présents dans le RSoP. + + GPResult contient déjà les appartenances aux groupes de sécurité + effectivement utilisées lors de l'évaluation des stratégies de groupe. + Réutiliser ces informations évite d'effectuer une requête LDAP + supplémentaire depuis le poste. + + Les groupes sont classés en quatre catégories : + + domain: + Groupe dont le préfixe correspond au domaine du compte RSoP. + + Exemple : + FORMATION\\Domain Computers + + local: + Groupes BUILTIN ou groupes préfixés par le nom du poste local. + + Exemples : + BUILTIN\\Users + PC001\\Administrators + + system: + Identités de sécurité Windows appartenant à NT AUTHORITY. + + Exemples : + NT AUTHORITY\\Authenticated Users + NT AUTHORITY\\NETWORK + + other: + Toute identité de sécurité qui ne peut pas être classée + de manière fiable dans l'une des catégories précédentes. + + Important : + Le préfixe du compte n'est considéré comme un domaine AD que s'il + est différent du nom du poste local. + + Exemple : + FORMATION\\PC001$ -> FORMATION est considéré comme un domaine. + + PC001\\localuser -> PC001 correspond au nom du poste ; + il s'agit donc d'un compte local et non d'un domaine AD. + + Returns: + dict contenant : + domain + local + system + other + """ + + groups = { + "domain": [], + "local": [], + "system": [], + "other": [], + } + + # Évite les doublons dans les noms de groupes. + seen = set() + + if scope_results is None: + return groups + + # Exemple : + # principal_name = "FORMATION\\WSMANAGE30$" + principal_domain, _ = _split_account( + principal_name + ) + + principal_domain = principal_domain.upper() + + computer_name = os.environ.get( + "COMPUTERNAME", + "" + ).upper() + + # Si le préfixe du compte correspond au nom de la machine, + # le compte est local. + # Dans ce cas, il n'existe pas de préfixe de domaine AD à utiliser + # pour classer les groupes. + 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() + + # Identités de sécurité intégrées à Windows. + if upper_name.startswith( + "NT AUTHORITY\\" + ): + groups["system"].append(name) + + # Groupes locaux intégrés. + elif upper_name.startswith( + "BUILTIN\\" + ): + groups["local"].append(name) + + # Groupes explicitement préfixés par le nom du poste local. + elif ( + computer_name + and upper_name.startswith( + computer_name + "\\" + ) + ): + groups["local"].append(name) + + # Groupes correspondant au vrai préfixe de domaine AD + # du compte évalué dans le RSoP. + elif ( + ad_domain + and upper_name.startswith( + ad_domain + "\\" + ) + ): + groups["domain"].append(name) + + # Les identités inconnues sont conservées plutôt que d'être + # classées à tort comme groupes de domaine. + else: + groups["other"].append(name) + + # Un tri alphabétique stable améliore la lisibilité de l'audit + # et évite des changements WAPT inutiles causés uniquement + # par un ordre différent dans les listes. + 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 COMPUTER et publie un résultat + structuré et compact dans les données d'audit WAPT. + + Le résultat contient : + - les informations du poste ; + - les GPO appliquées ; + - les GPO refusées ; + - les groupes AD ; + - le détail complet des groupes de sécurité classés. + """ - # Nom temporaire fixe conserve de la version fournie. xml_file = os.path.join( tempfile.gettempdir(), "wapt-gpresult-computer.xml" @@ -153,18 +419,26 @@ def audit(): try: - # Eviter de lire le rapport d'une execution precedente. + # Supprime un éventuel rapport restant d'une exécution précédente. if os.path.isfile(xml_file): os.remove(xml_file) - # /SCOPE COMPUTER cible les GPO machine ; /X produit le XML ; - # /F autorise le remplacement du fichier de destination. - command = '"%s" /SCOPE COMPUTER /X "%s" /F' % ( - makepath(system32(), "gpresult.exe"), - xml_file + gpresult_path = makepath( + system32(), + "gpresult.exe" ) - print("Running GPResult COMPUTER...") + command = ( + '"%s" /SCOPE COMPUTER /X "%s" /F' + % ( + gpresult_path, + xml_file + ) + ) + + print( + "Running GPResult COMPUTER..." + ) run( command, @@ -172,26 +446,53 @@ def audit(): return_stderr=False ) + # GPResult peut théoriquement terminer son exécution + # sans avoir généré le fichier XML attendu. if not os.path.isfile(xml_file): + raise Exception( "GPResult did not generate XML report" ) - tree = ET.parse(xml_file) + # Analyse du XML généré par GPResult. + tree = ET.parse( + xml_file + ) + root = tree.getroot() - computer_results = _find_computer_results(root) + computer_results = _find_computer_results( + root + ) if computer_results is None: + raise Exception( "ComputerResults section not found" ) + # Compte ordinateur évalué par GPResult. + # + # Exemple : + # FORMATION\\WSMANAGE30$ + rsop_computer = _text( + computer_results, + "Name" + ) + applied_gpos, denied_gpos = _get_gpos( computer_results ) - # Essayer DomainName, puis Domain si aucune valeur n'est trouvee. + security_groups = _get_security_groups( + computer_results, + rsop_computer + ) + + # Domaine DNS fourni par GPResult. + # + # Exemple : + # ad.formation.lan domain = _text( computer_results, "DomainName" @@ -203,16 +504,32 @@ def audit(): "Domain" ) + account_domain, account_name = _split_account( + rsop_computer + ) + + # Structure d'audit WAPT volontairement compacte + # et facilement exploitable en SQL / reporting. audit_data = { "status": "OK", "computer": { + "name": os.environ.get( "COMPUTERNAME", "" ), + + # Compte de sécurité complet du poste retourné par GPResult. + "account": rsop_computer, + + # Préfixe du compte au format NetBIOS. + "account_domain": account_domain, + + # Domaine DNS lorsqu'il est disponible. "domain": domain, + "site": _text( computer_results, "Site" @@ -220,21 +537,52 @@ def audit(): }, "summary": { - "applied_gpo_count": len(applied_gpos), - "denied_gpo_count": len(denied_gpos), + + "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"] + ), }, + # Les GPO sont conservées sous forme de simples listes de noms + # afin de faciliter leur exploitation en SQL / reporting. "applied_gpos": applied_gpos, "denied_gpos": denied_gpos, + + # Champ pratique contenant uniquement les groupes AD identifiés. + "domain_groups": + security_groups["domain"], + + # Le classement complet est conservé pour le diagnostic. + "security_groups": + security_groups, } - # Transmettre le dictionnaire directement pour conserver une - # donnee structuree, plutot qu'une chaine de texte JSON. WAPT.write_audit_data_if_changed( AUDIT_SECTION, AUDIT_NAME, @@ -244,19 +592,28 @@ def audit(): ) print( - "GPResult COMPUTER: %s applied / %s denied" + "GPResult COMPUTER: " + "%s applied / %s denied" % ( len(applied_gpos), len(denied_gpos) ) ) + print( + "AD groups: %s" + % len( + security_groups["domain"] + ) + ) + return "OK" except Exception as e: - # La meme cle est mise a jour avec l'erreur : les anciennes - # listes ne sont pas recopiees comme resultat de cet audit. + # Enregistre également l'erreur dans les données d'audit WAPT + # afin qu'elle soit visible depuis la console et pas uniquement + # dans les logs locaux de l'agent. WAPT.write_audit_data_if_changed( AUDIT_SECTION, AUDIT_NAME, @@ -277,10 +634,12 @@ def audit(): finally: - # Nettoyage tente apres succes comme apres erreur. - # Un echec de suppression est ignore dans la version conservee. + # Le fichier XML est uniquement un fichier de travail temporaire. + # Il n'a pas vocation à rester sur le poste. try: + if os.path.isfile(xml_file): os.remove(xml_file) - except: - pass + + except Exception: + pass \ No newline at end of file