Ajoute les groupes de sécurité à l'audit GPResult machine

This commit is contained in:
slaporte committed 2026-10-30 15:56:34 +01:00
1 parent 0fb81f2a9c
commit b48c4c5412
1 file changed
+407 -48
+407 -48
View File
@@ -1,9 +1,4 @@
# -*- coding: utf-8 -*- # -*- 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 * from setuphelpers import *
@@ -12,32 +7,63 @@ import tempfile
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
# Categorie et cle des donnees d'audit WAPT. # ---------------------------------------------------------------------------
# Identifiants de l'audit WAPT
# ---------------------------------------------------------------------------
AUDIT_SECTION = "audit-gpresult" AUDIT_SECTION = "audit-gpresult"
AUDIT_NAME = "computer-gpo" AUDIT_NAME = "computer-gpo"
def install(): 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 pass
# ---------------------------------------------------------------------------
# Fonctions utilitaires XML
# ---------------------------------------------------------------------------
def _clean_tag(tag): 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] return tag.split("}")[-1]
def _text(element, tag, default=""): 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 La recherche est récursive et ignore les namespaces XML.
n'est trouvee, retourne la valeur par defaut.
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: if element is None:
return default return default
for child in element.iter(): for child in element.iter():
if _clean_tag(child.tag).lower() == tag.lower(): if _clean_tag(child.tag).lower() == tag.lower():
if child.text: if child.text:
return child.text.strip() return child.text.strip()
@@ -45,7 +71,10 @@ def _text(element, tag, default=""):
def _to_bool(value): 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 ( return str(value).strip().lower() in (
"true", "true",
"1", "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): 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(): for element in root.iter():
if _clean_tag(element.tag) == "ComputerResults": if _clean_tag(element.tag) == "ComputerResults":
return element return element
return None 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/ def _get_gpos(scope_results):
version SYSVOL. Les GPO sans nom sont ignorees.
""" """
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 = [] applied = []
denied = [] denied = []
# Permet d'éviter les doublons de GPO présents dans le XML GPResult.
seen = set() seen = set()
if computer_results is None: if scope_results is None:
return applied, denied return applied, denied
# Conserver la recherche recursive de la version de reference. for element in scope_results.iter():
for element in computer_results.iter():
if _clean_tag(element.tag) != "GPO": if _clean_tag(element.tag) != "GPO":
continue 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: if not name:
continue continue
@@ -114,6 +200,8 @@ def _get_gpos(computer_results):
"VersionSysvol" "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 = ( identity = (
name, name,
version_directory, version_directory,
@@ -125,9 +213,6 @@ def _get_gpos(computer_results):
seen.add(identity) 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 ( if (
enabled enabled
and valid and valid
@@ -142,10 +227,191 @@ def _get_gpos(computer_results):
return applied, denied 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( xml_file = os.path.join(
tempfile.gettempdir(), tempfile.gettempdir(),
"wapt-gpresult-computer.xml" "wapt-gpresult-computer.xml"
@@ -153,18 +419,26 @@ def audit():
try: 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): if os.path.isfile(xml_file):
os.remove(xml_file) os.remove(xml_file)
# /SCOPE COMPUTER cible les GPO machine ; /X produit le XML ; gpresult_path = makepath(
# /F autorise le remplacement du fichier de destination. system32(),
command = '"%s" /SCOPE COMPUTER /X "%s" /F' % ( "gpresult.exe"
makepath(system32(), "gpresult.exe"),
xml_file
) )
print("Running GPResult COMPUTER...") command = (
'"%s" /SCOPE COMPUTER /X "%s" /F'
% (
gpresult_path,
xml_file
)
)
print(
"Running GPResult COMPUTER..."
)
run( run(
command, command,
@@ -172,26 +446,53 @@ def audit():
return_stderr=False 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): if not os.path.isfile(xml_file):
raise Exception( raise Exception(
"GPResult did not generate XML report" "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() root = tree.getroot()
computer_results = _find_computer_results(root) computer_results = _find_computer_results(
root
)
if computer_results is None: if computer_results is None:
raise Exception( raise Exception(
"ComputerResults section not found" "ComputerResults section not found"
) )
# Compte ordinateur évalué par GPResult.
#
# Exemple :
# FORMATION\\WSMANAGE30$
rsop_computer = _text(
computer_results,
"Name"
)
applied_gpos, denied_gpos = _get_gpos( applied_gpos, denied_gpos = _get_gpos(
computer_results 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( domain = _text(
computer_results, computer_results,
"DomainName" "DomainName"
@@ -203,16 +504,32 @@ def audit():
"Domain" "Domain"
) )
account_domain, account_name = _split_account(
rsop_computer
)
# Structure d'audit WAPT volontairement compacte
# et facilement exploitable en SQL / reporting.
audit_data = { audit_data = {
"status": "OK", "status": "OK",
"computer": { "computer": {
"name": os.environ.get( "name": os.environ.get(
"COMPUTERNAME", "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, "domain": domain,
"site": _text( "site": _text(
computer_results, computer_results,
"Site" "Site"
@@ -220,21 +537,52 @@ def audit():
}, },
"summary": { "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": ( "total_gpo_count": (
len(applied_gpos) len(applied_gpos)
+ len(denied_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, "applied_gpos": applied_gpos,
"denied_gpos": denied_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( WAPT.write_audit_data_if_changed(
AUDIT_SECTION, AUDIT_SECTION,
AUDIT_NAME, AUDIT_NAME,
@@ -244,19 +592,28 @@ def audit():
) )
print( print(
"GPResult COMPUTER: %s applied / %s denied" "GPResult COMPUTER: "
"%s applied / %s denied"
% ( % (
len(applied_gpos), len(applied_gpos),
len(denied_gpos) len(denied_gpos)
) )
) )
print(
"AD groups: %s"
% len(
security_groups["domain"]
)
)
return "OK" return "OK"
except Exception as e: except Exception as e:
# La meme cle est mise a jour avec l'erreur : les anciennes # Enregistre également l'erreur dans les données d'audit WAPT
# listes ne sont pas recopiees comme resultat de cet audit. # afin qu'elle soit visible depuis la console et pas uniquement
# dans les logs locaux de l'agent.
WAPT.write_audit_data_if_changed( WAPT.write_audit_data_if_changed(
AUDIT_SECTION, AUDIT_SECTION,
AUDIT_NAME, AUDIT_NAME,
@@ -277,10 +634,12 @@ def audit():
finally: finally:
# Nettoyage tente apres succes comme apres erreur. # Le fichier XML est uniquement un fichier de travail temporaire.
# Un echec de suppression est ignore dans la version conservee. # Il n'a pas vocation à rester sur le poste.
try: try:
if os.path.isfile(xml_file): if os.path.isfile(xml_file):
os.remove(xml_file) os.remove(xml_file)
except:
pass except Exception:
pass