Auditoría de acceso condicional
Lista las políticas de acceso condicional con su estado y sus exclusiones con nombre. Señala las apagadas o en solo informe, porque esas no protegen a nadie aunque existan.
Ejemplo en México
Aseguradora regional en Puebla, 300 personas
Para una auditoría interna tienen que documentar sus controles de acceso, y el responsable anterior dejó varias políticas de prueba sin explicar.
.\get-auditoria-acceso-condicional.ps1 -RutaCsv .\acceso-condicional.csvVe que la política que exige MFA sigue en solo informe y que excluye a un grupo de 40 personas. La enciende, reduce el grupo y conserva el CSV para documentar el antes y el después.
Cómo implementarlo
- Abre PowerShell 7 con el módulo Microsoft.Graph.Authentication instalado.
- Corre el script con -RutaCsv e inicia sesión con una cuenta con el rol Lector global.
- Revisa las filas con Alerta distinta de Activa, que aparecen primero en el CSV.
- Revisa cada exclusión, sobre todo las marcadas como no encontrado, y decide cuáles quitar o qué política encender.
Ver el código completo de get-auditoria-acceso-condicional.ps1
<#
.SYNOPSIS
¿Qué políticas de acceso condicional están apagadas o en solo informe, y a quién excluyen?
.DESCRIPTION
Lista cada política con su estado y sus exclusiones de usuarios, grupos, roles y aplicaciones con nombre legible.
Señala las apagadas y las que están en modo solo informe, porque ninguna de las dos protege a nadie.
Una exclusión que apunta a un objeto borrado se marca como no encontrada.
Solo lectura: no cambia nada en el tenant. El POST a getByIds es una consulta.
.PARAMETER RutaCsv
Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.
.PARAMETER ZonaHoraria
Zona horaria para mostrar las fechas. Por defecto, Central Standard Time (Mexico).
.EXAMPLE
.\get-auditoria-acceso-condicional.ps1 -RutaCsv .\acceso-condicional.csv
.NOTES
Permisos: Policy.Read.All, Directory.Read.All
Rol mínimo: Lector global (Global Reader)
Licencia: Microsoft Entra ID P1
Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en un tenant de pruebas.
#>
[CmdletBinding()]
param(
[string]$RutaCsv,
[string]$ZonaHoraria = 'Central Standard Time (Mexico)'
)
$ErrorActionPreference = 'Stop'
$permisos = @('Policy.Read.All', 'Directory.Read.All')
if (-not (Get-Command Connect-MgGraph -ErrorAction SilentlyContinue)) {
throw 'Falta el módulo. Instálalo con: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser'
}
$zona = [TimeZoneInfo]::FindSystemTimeZoneById($ZonaHoraria)
Connect-MgGraph -Scopes $permisos -NoWelcome
function Get-TodasLasPaginas {
param([string]$Uri)
while ($Uri) {
$pagina = Invoke-MgGraphRequest -Method GET -Uri $Uri
$pagina.value
$Uri = $pagina.'@odata.nextLink'
}
}
function Format-FechaLocal {
param($Valor)
if (-not $Valor) { return $null }
$utc = if ($Valor -is [datetimeoffset]) { $Valor.UtcDateTime }
elseif ($Valor -is [datetime]) { if ($Valor.Kind -eq 'Unspecified') { [datetime]::SpecifyKind($Valor, 'Utc') } else { $Valor.ToUniversalTime() } }
else { [datetimeoffset]::Parse("$Valor", [cultureinfo]::InvariantCulture).UtcDateTime }
[TimeZoneInfo]::ConvertTimeFromUtc($utc, $zona).ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture)
}
$base = 'https://graph.microsoft.com/v1.0'
$politicas = @(Get-TodasLasPaginas "$base/identity/conditionalAccess/policies")
$roles = @{}
foreach ($d in Get-TodasLasPaginas "$base/roleManagement/directory/roleDefinitions") { $roles[$d.id] = $d.displayName }
# getByIds acepta 1000 identificadores por llamada. Los valores que no son GUID (como All) no se consultan.
$regexGuid = '^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$'
$ids = @($politicas | ForEach-Object { $_.conditions.users.excludeUsers; $_.conditions.users.excludeGroups } | Where-Object { $_ -match $regexGuid } | Select-Object -Unique)
$nombres = @{}
for ($i = 0; $i -lt $ids.Count; $i += 1000) {
$cuerpo = @{ ids = @($ids[$i..([math]::Min($i + 999, $ids.Count - 1))]); types = @('user', 'group') } | ConvertTo-Json
foreach ($o in (Invoke-MgGraphRequest -Method POST -Uri "$base/directoryObjects/getByIds" -Body $cuerpo -ContentType 'application/json').value) {
$nombres[$o.id] = if ($o.userPrincipalName) { $o.userPrincipalName } else { $o.displayName }
}
}
function Get-Nombres {
param($Valores, [hashtable]$Mapa)
(@($Valores) | Where-Object { $_ } | ForEach-Object {
if ($Mapa[$_]) { $Mapa[$_] } elseif ($_ -match $regexGuid) { "$_ (no encontrado)" } else { $_ }
}) -join '; '
}
$filas = foreach ($p in $politicas) {
$usuarios = $p.conditions.users
[pscustomobject]@{
Politica = $p.displayName
Estado = $p.state
Alerta = switch ($p.state) {
'enabled' { 'Activa' }
'disabled' { 'APAGADA' }
'enabledForReportingButNotEnforced' { 'SOLO INFORME: no bloquea a nadie' }
default { "Estado desconocido: $($p.state)" }
}
ExcluyeUsuarios = Get-Nombres $usuarios.excludeUsers $nombres
ExcluyeGrupos = Get-Nombres $usuarios.excludeGroups $nombres
ExcluyeRoles = Get-Nombres $usuarios.excludeRoles $roles
ExcluyeExternos = if ($usuarios.excludeGuestsOrExternalUsers) { 'Sí' } else { 'No' }
ExcluyeApps = (@($p.conditions.applications.excludeApplications) | Where-Object { $_ }) -join '; '
ModificadaEl = Format-FechaLocal $p.modifiedDateTime
}
}
$filas = $filas | Sort-Object @{ Expression = { $_.Estado -eq 'enabled' } }, Politica
Write-Host "Políticas: $(@($filas).Count). Sin aplicarse (apagadas o en solo informe): $(@($filas | Where-Object Estado -ne 'enabled').Count)"
if ($RutaCsv) {
# Excel ejecutaría como fórmula un nombre que empiece con estos caracteres.
foreach ($fila in $filas) { foreach ($c in $fila.PSObject.Properties) { if ($c.Value -is [string] -and $c.Value -match '^[=+\-@]') { $c.Value = "'" + $c.Value } } }
# UTF-8 con BOM para que Excel en español muestre bien los acentos.
$filas | Export-Csv -Path $RutaCsv -NoTypeInformation -Encoding utf8BOM
Write-Host "CSV guardado en $RutaCsv"
} else {
$filas | Format-Table -AutoSize
}