Casi todas las herramientas de auditoría de Microsoft 365 dicen lo mismo en su README: read-only, no modifica tu tenant. Y casi todas lo dicen de la misma forma: como una promesa.
Una promesa en un README no sobrevive al segundo mantenedor. Alguien añade una comprobación, necesita un dato que solo se obtiene provocando un cambio, y seis meses después tu herramienta "de solo lectura" escribe en producción. Nadie mintió. Simplemente la propiedad no estaba sujeta a nada.
Lo que sigue es cómo convertir esa frase en algo que falle la compilación cuando deja de ser cierta.
El problema, en concreto
Microsoft Graph no separa lectura y escritura por endpoint, sino por verbo. El mismo /users/{id} es lectura con GET y escritura con PATCH. Así que "de solo lectura" se traduce a una invariante muy simple:
En todo el módulo, la única llamada a Graph usa
GET.
Esa frase sí se puede comprobar con una máquina.
Paso 1: un único punto de salida
La invariante no se puede verificar si hay quince sitios distintos que llaman a Graph. Lo primero es que haya uno:
function Invoke-HygieneGraphRequest { param( [Parameter(Mandatory)][string] $Uri, [int] $MaxPages = 50 ) # El verbo va fijado aqui y no se expone como parametro. No es por comodidad: # un parametro -Method convierte la invariante en una convencion, y una # convencion la rompe el primero que tenga prisa. Invoke-MgGraphRequest -Method GET -Uri $Uri -ErrorAction Stop } Lo importante no es el GET. Es que no hay un parámetro -Method. Si lo hubiera, la invariante dependería de que todos los llamantes se porten bien, y eso no se puede verificar.
Paso 2: la prueba que lo sujeta
Describe 'El modulo no escribe en el tenant' { It 'ningun fichero contiene un verbo de escritura de Graph' { $ficheros = Get-ChildItem -Path $ModuloRaiz -Recurse -Include '*.ps1','*.psm1' $infractores = foreach ($f in $ficheros) { $texto = Get-Content -LiteralPath $f.FullName -Raw if ($texto -match '-Method\s+(POST|PATCH|PUT|DELETE)') { '{0}:{1}' -f $f.Name, $Matches[1] } } $infractores | Should -BeNullOrEmpty } It 'solo hay un sitio que llame a Graph' { $llamadas = Select-String -Path "$ModuloRaiz\**\*.ps1" ` -Pattern 'Invoke-MgGraphRequest' -AllMatches # Una sola: la de Invoke-HygieneGraphRequest. Si aparece otra, alguien # se salto el punto de salida unico y la primera prueba deja de cubrirlo. @($llamadas).Count | Should -Be 1 } } La segunda prueba es la que de verdad importa, y es la que casi nadie escribe. Sin ella, la primera se puede burlar sin querer: basta con que alguien llame a Invoke-MgGraphRequest directamente con GET, y a partir de ahí ya hay dos caminos, uno de ellos sin guardia.
Lo que esto NO prueba
Y aquí es donde conviene ser honesto, porque una prueba que se cree más de lo que mide es peor que ninguna.
- No prueba que no haya efectos de escritura. Un
GETpuede tener efectos secundarios en el servidor. En Graph, en la práctica, no los tiene en los endpoints de lectura de directorio, pero la prueba no lo demuestra: lo asume. - Es una comprobación de texto, no de comportamiento. Un verbo construido en tiempo de ejecución (
-Method $v) se le escapa. Por eso la segunda prueba, la del punto de salida único, no es opcional: es la que cierra ese hueco. - No cubre lo que hagan los módulos de los que dependes. Si importas algo que escribe, tu suite no se entera.
Esas tres limitaciones están en el README del módulo, no solo en este artículo. Una herramienta de seguridad que no publica sus puntos ciegos está pidiendo que confíes en ella por fe.
El patrón, generalizado
La idea no es de PowerShell ni de Graph:
- Escribe la propiedad como una frase que una máquina pueda refutar. "Es seguro" no vale. "La única llamada a la red usa GET" sí.
- Reduce los puntos donde la propiedad puede romperse hasta que quede uno.
- Escribe la prueba que falla cuando se rompe.
- Escribe también la prueba de que sigue habiendo un solo punto. Es la que evita que la propiedad se degrade en silencio.
- Publica lo que la prueba no cubre.
El paso 4 es el que separa una invariante de un comentario con suerte.
Si quieres verlo entero
Está en M365TenantHygiene y EntraHygiene, los dos MIT y en la PowerShell Gallery:
Install-Module M365TenantHygiene -Scope CurrentUser El mismo módulo aplica otra idea emparentada que merece su propio artículo: distinguir "no encontré nada" de "no pude mirar". Una comprobación que no pudo ejecutarse informa Skipped con el motivo, nunca un resultado limpio. Un informe de auditoría que confunde esas dos cosas es peor que no tener informe, porque produce tranquilidad sin cobertura.
Si encuentras un hueco en el razonamiento, dímelo. Prefiero el comentario incómodo.