Un informe de cambios de Active Directory desde PowerShell
Hace unos días publiqué un código de PowerShell que podría usar para recibir alertas cuando algo cambiara en Active Directory. El código utilizó eventos de PowerShell y CIM para notificarle, por ejemplo, cuando se crea una nueva cuenta de usuario. Esto puede resultar útil cuando necesite alertas. Pero quizás sólo necesites informar. ¿Qué ha cambiado en Active Directory desde una fecha y hora determinadas, como por ejemplo en las últimas 24 horas? ¿Y no sería bueno tener un bonito informe? Déjame ayudar. Así es como abordé el problema usando PowerShell y el módulo ActiveDirectory.
Cuando se cambia
Los objetos en Active Directory tienen una propiedad WhenChanged . Muchos de los comandos de ActiveDirectory tienen un parámetro de filtro. Este parámetro es muy flexible y definitivamente querrás leer la ayuda del comando. La razón para usar el filtro es que los objetos se filtran en la fuente, lo que es mucho más rápido. Esta NO sería la forma recomendada de filtrar:
$since = (Get-Date).AddMinutes(-90)
Get-ADuser -filter * -properties WhenChanged | Where { $_.WhenChanged -ge $since }Funcionará, pero piénsalo. Le está diciendo a PowerShell: "Consígame todos los objetos de usuario del dominio, envíelos a mi computadora y luego filtre dónde la propiedad WhenChanged es mayor que los últimos 90 minutos". En un dominio pequeño, no lo notarás. Pero si tienes un dominio grande, definitivamente hay una diferencia.
En su lugar, cree un filtro como este:
Get-ADuser -filter {WhenChanged -ge $since} -Properties WhenChanged |
Select-Object DistinguishedName,Name,WhenChanged
La parte complicada es que necesitas crear este filtro en particular como un bloque de script. No he hecho ningún script de AD desde hace tiempo y me llevó algún tiempo solucionarlo. Releí la ayuda del cmdlet y eso me llevó en la dirección correcta. Lea siempre la ayuda.
Aquí están mis resultados. Por supuesto, no tengo idea de qué cambió, pero eso no me preocupa. Una cosa a tener en cuenta es que es posible que haya cambiado objetos donde el único cambio se debe a la replicación y una actualización de USN. No he encontrado una buena manera de filtrar ese tipo de cambios.
Obtener-ADObject
Para mi informe, sólo me importan los usuarios, grupos, computadoras y unidades organizativas. Si bien podría usar el cmdlet Get-* específico, probablemente sea igual de fácil usar Get-ADObject y crear un buen filtro.
$filter = {(objectclass -eq 'user' -or objectclass -eq 'group' -or objectclass -eq 'organizationalunit' ) -AND (WhenChanged -gt $since )}Esta es una forma en que podría usar este filtro.
Get-ADObject -Filter $filter -Properties WhenCreated,WhenChanged |
Sort-Object -Property ObjectClass,WhenChanged |
Format-Table -GroupBy objectClass -Property DistinguishedName,WhenCreated,WhenChangedUna cosa a señalar es que si WhenCreated es mayor o igual a mi fecha límite, lo más probable es que se trate de un objeto nuevo.
Otra razón para usar Get-ADObject es que también mostrará objetos eliminados. Sin embargo, debe habilitar la Papelera de reciclaje de Active Directory. Pero si es así, aquí tienes el código revisado:
Get-ADObject -Filter $filter -Properties WhenCreated,WhenChanged -IncludeDeletedObjects |
Select-Object *,@{Name="IsNew";Expression = { if ($_.WhenCreated -ge $since) { $True} else {$False}}},
@{Name="IsDeleted";Expression = {$_.distinguishedname -match "Deleted Objects" }} |
Sort-Object -Property ObjectClass,WhenChanged |
Format-List -GroupBy objectClass -Property DistinguishedName,WhenCreated,WhenChanged,IsNew,IsDeletedEstoy definiendo algunas propiedades adicionales para indicar objetos nuevos o eliminados.
Hay una propiedad llamada IsDeleted que también puedes usar, aunque solo existirá en objetos eliminados. Mi código lo define para todos los objetos.
Una vez que tenga los objetos y las propiedades, no hay fin a cómo puede formatear o utilizar los resultados.
Crear un informe HTML
Me encanta crear informes y mi herramienta favorita es ConvertTo-Html. En mis guiones de informes, tiendo a crear archivos independientes con estilo incrustado. Este es un buen enfoque si planea enviar archivos por correo electrónico. Mi secuencia de comandos es en realidad una secuencia de comandos de control que incluye los cmdlets AD, ConvertTo-HTML y Out-File. No estoy escribiendo una función para enviar objetos a la canalización. Estoy ejecutando un conjunto orquestado de comandos de PowerShell para producir el resultado deseado. Este es un script de control.
Estos tipos de scripts también pueden tener parámetros. Muy a menudo, paso los parámetros del script a los cmdlets subyacentes, a menudo usando PSBoundParameters y splatting. Aunque últimamente he estado usando una versión privada $PSDefaultParameterValues, que es la que estoy usando en este script. Lo cual también podría mostrarles ahora.
#requires -module ActiveDirectory
#Reporting on deleted items requires the Active Directory Recycle Bin feature
[cmdletbinding()]
Param(
[Parameter(Position = 0,HelpMessage = "Enter a last modified datetime for AD objects. The default is the last 8 hours.")]
[ValidateNotNullOrEmpty()]
[datetime]$Since = ((Get-Date).AddHours(-8)),
[Parameter(HelpMessage = "What is the report title?")]
[string]$ReportTitle = "Active Directory Change Report",
[Parameter(HelpMessage = "Add a second grouping based on the object's container or OU.")]
[switch]$ByContainer,
[Parameter(HelpMessage = "Specify the path for the output file.")]
[ValidateNotNullOrEmpty()]
[string]$Path = ".\ADChangeReport.html",
[Parameter(HelpMessage = "Specifies the Active Directory Domain Services domain controller to query. The default is your Logon server.")]
[string]$Server = $env:LOGONSERVER.SubString(2),
[Parameter(HelpMessage = "Specify an alternate credential for authentication.")]
[pscredential]$Credential,
[ValidateSet("Negotiate","Basic")]
[string]$AuthType
)
#region helper functions
#a private helper function to convert the objects to html fragments
Function _convertObjects {
Param([object[]]$Objects)
#convert each table to an XML fragment so I can insert a class attribute
[xml]$frag = $objects | Sort-Object -property WhenChanged |
Select-Object -Property DistinguishedName,Name,WhenCreated,WhenChanged,IsDeleted |
ConvertTo-Html -Fragment
for ($i = 1; $i -lt $frag.table.tr.count;$i++) {
if (($frag.table.tr[$i].td[2] -as [datetime]) -ge $since) {
#highlight new objects in green
$class = $frag.CreateAttribute("class")
$class.value="new"
[void]$frag.table.tr[$i].Attributes.append($class)
} #if new
#insert the alert attribute if the object has been deleted.
if ($frag.table.tr[$i].td[-1] -eq 'True') {
#highlight deleted objects in red
$class = $frag.CreateAttribute("class")
$class.value="alert"
[void]$frag.table.tr[$i].Attributes.append($class)
} #if deleted
} #for
#write the innerXML (ie HTML code) as the function output
$frag.InnerXml
}
# private helper function to insert javascript code into my html
function _insertToggle {
[cmdletbinding()]
#The text to display, the name of the div, the data to collapse, and the heading style
#the div Id needs to be simple text
Param([string]$Text, [string]$div, [object[]]$Data, [string]$Heading = "H2", [switch]$NoConvert)
$out = [System.Collections.Generic.list[string]]::New()
if (-Not $div) {
$div = $Text.Replace(" ", "_")
}
$out.add("<a href='javascript:toggleDiv(""$div"");' title='click to collapse or expand this section'><$Heading>$Text</$Heading></a><div id=""$div"">")
if ($NoConvert) {
$out.Add($Data)
}
else {
$out.Add($($Data | ConvertTo-Html -Fragment))
}
$out.Add("</div>")
$out
}
#endregion
#some report metadata
$reportVersion = "2.1.1"
$thisScript = Convert-Path $myinvocation.InvocationName
Write-Verbose "[$(Get-Date)] Starting $($myinvocation.MyCommand)"
Write-Verbose "[$(Get-Date)] Detected these bound parameters"
$PSBoundParameters | Out-String | Write-Verbose
#set some default parameter values
$params = "Credential","AuthType"
$script:PSDefaultParameterValues = @{"Get-AD*:Server" = $Server}
ForEach ($param in $params) {
if ($PSBoundParameters.ContainsKey($param)) {
Write-Verbose "[$(Get-Date)] Adding 'Get-AD*:$param' to script PSDefaultParameterValues"
$script:PSDefaultParameterValues["Get-AD*:$param"] = $PSBoundParameters.Item($param)
}
}
Write-Verbose "[$(Get-Date)] Getting current Active Directory domain"
$domain = Get-ADDomain
#create a list object to hold all of the HTML fragments
Write-Verbose "[$(Get-Date)] Initializing fragment list"
$fragments = [System.Collections.Generic.list[string]]::New()
$fragments.Add("<H2>$($domain.dnsroot)</H2>")
$fragments.Add("<a href='javascript:toggleAll();' title='Click to toggle all sections'>+/-</a>")
Write-Verbose "[$(Get-Date)] Querying $($domain.dnsroot)"
$filter = {(objectclass -eq 'user' -or objectclass -eq 'group' -or objectclass -eq 'organizationalunit' ) -AND (WhenChanged -gt $since )}
Write-Verbose "[$(Get-Date)] Filtering for changed objects since $since"
$items = Get-ADObject -filter $filter -IncludeDeletedObjects -Properties WhenCreated,WhenChanged,IsDeleted -OutVariable all | Group-Object -property objectclass
Write-Verbose "[$(Get-Date)] Found $($all.count) total items"
if ($items.count -gt 0) {
foreach ($item in $items) {
$category = "{0}{1}" -f $item.name[0].ToString().toUpper(),$item.name.Substring(1)
Write-Verbose "[$(Get-Date)] Processing $category [$($item.count)]"
if ($ByContainer) {
Write-Verbose "[$(Get-Date)] Organizing by container"
$subgroup = $item.group | Group-Object -Property { $_.distinguishedname.split(',', 2)[1] } | Sort-Object -Property Name
$fraghtml = [System.Collections.Generic.list[string]]::new()
foreach ($subitem in $subgroup) {
Write-Verbose "[$(Get-Date)] $($subItem.name)"
$fragGroup = _convertObjects $subitem.group
$divid = $subitem.name -replace "=|,",""
$fraghtml.Add($(_inserttoggle -Text "$($subItem.name) [$($subitem.count)]" -div $divid -Heading "H4" -Data $fragGroup -NoConvert))
} #foreach subitem
} #if by container
else {
$fragHtml = _convertObjects $item.group
}
$code = _insertToggle -Text "$category [$($item.count)]" -div $category -Heading "H3" -Data $fragHtml -NoConvert
$fragments.Add($code)
} #foreach item
#my embedded CSS
$head = @"
<Title>$ReportTitle</Title>
<style>
h2 {
width:95%;
background-color:#7BA7C7;
font-family:Tahoma;
font-size:12pt;
}
h4 {
width:95%;
background-color:#b5f144;
}
body {
background-color:#FFFFFF;
font-family:Tahoma;
font-size:12pt;
}
td, th {
border:1px solid black;
border-collapse:collapse;
}
th {
color:white;
background-color:black;
}
table, tr, td, th {
padding-left: 10px;
margin: 0px
}
tr:nth-child(odd) {background-color: lightgray}
table {
width:95%;
margin-left:5px;
margin-bottom:20px;
}
.alert { color:red; }
.new { color:green; }
.footer { font-size:10pt; }
.footer tr:nth-child(odd) {background-color: white}
.footer td,tr {
border-collapse:collapse;
border:none;
}
.footer table {width:15%;}
td.size {
text-align: right;
padding-right: 25px;
}
</style>
<script type='text/javascript' src='https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js'>
</script>
<script type='text/javascript'>
function toggleDiv(divId) {
`$("#"+divId).toggle();
}
function toggleAll() {
var divs = document.getElementsByTagName('div');
for (var i = 0; i < divs.length; i++) {
var div = divs[i];
`$("#"+div.id).toggle();
}
}
</script>
<H1>$ReportTitle</H1>
"@
#a footer for the report. This could be styled with CSS
$post = @"
<table class='footer'>
<tr align = "right"><td>Report run: <i>$(Get-Date)</i></td></tr>
<tr align = "right"><td>Report version: <i>$ReportVersion</i></td></tr>
<tr align = "right"><td>Source: <i>$thisScript</i></td></tr>
</table>
"@
$htmlParams = @{
Head = $head
precontent = "Active Directory changes since $since. Reported from $($Server.toUpper()). Replication only changes may be included."
Body =($fragments | Out-String)
PostContent = $post
}
Write-Verbose "[$(Get-Date)] Creating report $ReportTitle version $reportversion saved to $path"
ConvertTo-HTML @htmlParams | Out-File -FilePath $Path
Get-Item -Path $Path
}
else {
Write-Warning "No modified objects found in the $($domain.dnsroot) domain since $since."
}
Write-Verbose "[$(Get-Date)] Ending $($myinvocation.MyCommand)"El script crea una serie de fragmentos HTML que finalmente se juntan para crear el archivo final. Mi script incluye algunas funciones auxiliares. Uno de ellos analiza el HTML como XML para que pueda insertar un atributo de clase para indicar si el objeto es nuevo o eliminado. Tengo un estilo definido que muestra los objetos eliminados en rojo y los objetos nuevos en verde. La otra función me ayuda a insertar javascript que me permite contraer secciones del archivo.
c:\scripts\ADChangeReport.ps1 -Since (Get-Date).Addhours(-1) -Path c:\scripts\ad6.htmlLa secuencia de comandos utilizará de forma predeterminada el servidor de inicio de sesión para el usuario actual, aunque puede especificar un controlador de dominio diferente. También puede especificar credenciales alternativas. Puede y debe ejecutar el script desde el escritorio de un miembro del dominio.
Puedo hacer clic en +/- para alternar el colapso de todas las regiones, o hacer clic en las diferentes categorías. Puedes ver el nuevo grupo en texto verde.
En la parte inferior del informe puedes ver al usuario eliminado en rojo. También me gusta incluir información de metadatos en el pie de página de mis informes. Esta información me ayuda a saber dónde se originó el informe. Esto puede resultar útil si configura un sistema de informes programado pero olvida documentarlo. Un dato que falta en este informe es el nombre de la computadora. Puedo ver la ruta del archivo de script pero no sé en qué computadora. Te dejaré ese ajuste a ti.
El script de informes también admite la organización de los resultados por contenedor. Mi código se agrupará en el contenedor del objeto que obtengo al dividir el nombre distintivo.
if ($ByContainer) {
Write-Verbose "[$(Get-Date)] Organizing by container"
$subgroup = $item.group | Group-Object -Property { $_.distinguishedname.split(',', 2)[1] } | Sort-Object -Property Name
...Resumen
Es cierto que este es un script de PowerShell complicado, por lo que si hay algo que no comprende y sobre lo que tiene preguntas, no dude en preguntar en los comentarios. También señalaré que, al igual que el monitor de eventos de AD, el hecho de que puedas usar algo como esto no significa que debas hacerlo. Los dominios pequeños con cambios mínimos podrían funcionar con este script o algo similar.
Sin embargo, desconfiaría de usar esto en una gran empresa con una infraestructura de Active Directory altamente dinámica. Aunque si tu ventana de cambio fuera pequeña, podría resultar eficaz. Supongo que si está ejecutando un entorno AD grande, su empresa ha invertido en herramientas de gestión e informes adecuadas y de alta calidad. Puede aprovechar PowerShell para esos nichos o situaciones especiales.
Si puedes probar el guión de informes, me encantaría conocer tus experiencias. ¡Disfrutar!






