Files
Audit-gpresult-machine/setup.py
T

645 lines
16 KiB
Python

# -*- coding: utf-8 -*-
from setuphelpers import *
import os
import tempfile
import xml.etree.ElementTree as ET
# ---------------------------------------------------------------------------
# Identifiants de l'audit WAPT
# ---------------------------------------------------------------------------
AUDIT_SECTION = "audit-gpresult"
AUDIT_NAME = "computer-gpo"
def install():
"""
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):
"""
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=""):
"""
Retourne le contenu texte du premier élément XML correspondant.
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()
return default
def _to_bool(value):
"""
Convertit les représentations textuelles courantes d'un booléen
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\\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 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
# ---------------------------------------------------------------------------
# Analyse des GPO
# ---------------------------------------------------------------------------
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 scope_results is None:
return applied, denied
for element in scope_results.iter():
if _clean_tag(element.tag) != "GPO":
continue
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
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"
)
# 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,
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é 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.
"""
xml_file = os.path.join(
tempfile.gettempdir(),
"wapt-gpresult-computer.xml"
)
try:
# 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"
)
command = (
'"%s" /SCOPE COMPUTER /X "%s" /F'
% (
gpresult_path,
xml_file
)
)
print(
"Running GPResult COMPUTER..."
)
run(
command,
timeout=120,
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"
)
# Analyse du XML généré par GPResult.
tree = ET.parse(
xml_file
)
root = tree.getroot()
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
)
security_groups = _get_security_groups(
computer_results,
rsop_computer
)
# Domaine DNS fourni par GPResult.
#
# Exemple :
# ad.formation.lan
domain = _text(
computer_results,
"DomainName"
)
if not domain:
domain = _text(
computer_results,
"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"
),
},
"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"]
),
},
# 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,
}
WAPT.write_audit_data_if_changed(
AUDIT_SECTION,
AUDIT_NAME,
audit_data,
keep_days=365,
max_count=1
)
print(
"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:
# 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,
{
"status": "ERROR",
"message": str(e)
},
keep_days=365,
max_count=1
)
print(
"GPResult COMPUTER audit error: %s"
% str(e)
)
return "ERROR"
finally:
# 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 Exception:
pass