Hay una categoría de fallo que ningún registro de errores le mostrará jamás, porque nada falla. La API responde. El valor se parsea. El informe se dibuja. Y la respuesta está equivocada de la manera específica que acaba con carreras: está equivocada con aire de autoridad.

Chocamos con ella tres veces en una sola temporada, construyendo un motor de evidencia para la gobernanza de Microsoft 365. Cada vez el mecanismo fue distinto, cada vez es reproducible, y cada vez la corrección fue estructural y no un parche. Esta es la versión larga, con el código.

Forma uno: el valor por defecto que se lee como hallazgo

SharingCapability es la propiedad que dice cuál es el uso compartido externo más permisivo que un sitio de SharePoint permite. Sus cuatro valores, por orden de exposición:

Text
Disabled                          no external sharing
ExistingExternalUserSharingOnly   guests already in the directory
ExternalUserSharingOnly           new and existing guests, sign-in required
ExternalUserAndGuestSharing       Anyone links: no sign-in, no identity

La manera obvia de inventariar un tenant es enumerar cada sitio una vez y leer la propiedad de cada uno:

PowerShell
# The tempting one-liner. It is also wrong, and Microsoft says so.
Get-SPOSite -Limit All | Select-Object Url, SharingCapability

Esta es la nota que Microsoft pone en la referencia de Get-SPOSite, citada literalmente:

If the Limit or Filter parameters are provided then the following site collection properties will not be populated and may contain a default value: AllowDownloadingNonWebViewableFiles, AllowEditing, [...] DefaultLinkPermission, DefaultSharingLinkType, [...] SensitivityLabel, [...] SharingCapability, SharingDomainRestrictionMode.

Veintitrés propiedades. En el momento en que pasa -Limit o -Filter, ninguna se lee; cada una devuelve el valor por defecto de su tipo. SharingCapability es un enum de .NET, su valor por defecto es el miembro cero, y el miembro cero es Disabled.

Lea la cadena otra vez, despacio. La enumeración en bloque, lo primero a lo que recurre cualquier script de inventario, devuelve el valor más cerrado del vocabulario para cada propiedad que no leyó. No es null. No es un error. Es la palabra más segura posible, en un sitio al que nunca llegó a mirar.

La lectura correcta es por sitio, por identidad:

PowerShell
# One site, actually populated. Note the admin endpoint: sharing capability
# is a tenant property about a site, not a property of the site object.
Connect-PnPOnline -Url https://contoso-admin.sharepoint.com -Interactive -ClientId $appId
(Get-PnPTenantSite -Identity https://contoso.sharepoint.com/sites/finance).SharingCapability

Las dos discrepan, y discrepan en silencio. Lo reprodujimos contra un tenant real y un sitio de cada cinco difería: la enumeración decía Disabled, la lectura directa decía ExternalUserAndGuestSharing. Un sitio repartiendo enlaces Anyone, reportado como sellado, por una API correcta haciendo exactamente lo que dice su documentación.

Un panel construido sobre el primer fragmento muestra una pared de verde. Cada tarjeta es una llamada de API que funciona. Cada tarjeta puede estar equivocada.

La respuesta de ingeniería no es "acuérdate siempre de leer por sitio". Nadie se acuerda de forma fiable. La respuesta es que un valor leído por un camino que se sabe que no lo rellena debe registrarse como que no es evidencia, de forma estructural, para que nada aguas abajo pueda consumirlo:

PowerShell
# From the collector. The enumerated value is never trusted; it is only
# used to obtain the list of URLs to read properly.
$urls = Get-PnPTenantSite | Select-Object -ExpandProperty Url        # the list, not the facts
foreach ($u in $urls) {
    $site = Get-PnPTenantSite -Identity $u                            # the populated read
    New-ScalarFact -Value ([string]$site.SharingCapability) -RawField 'SharingCapability'
}

La enumeración queda rebajada a aquello para lo que sirve honestamente (producir una lista de direcciones) y el hecho se toma siempre de la lectura que Microsoft documenta como completa.

Forma dos: la procedencia que nadie comprobó

La segunda la encontramos en nuestro propio repositorio, que es el lugar honesto para encontrarla.

Cada documento de evidencia que entregamos lleva un bloque provenance: cuándo se recogió, con qué, a través de qué API. Veintisiete fixtures declaraban esto:

JSON
{
  "provenance": {
    "collected_at": "2026-08-05T14:02:11Z",
    "collector": "spo-collector",
    "source_api": "Microsoft Graph v1.0"
  }
}

El recolector nunca ha usado Graph. Lee a través de PnP.PowerShell y CSOM; sus caminos de tenant pasan por la API de administración de SharePoint. Microsoft Graph v1.0 se copió hacia adelante desde una suposición de la primera semana y nunca se cuestionó, porque toda comprobación que teníamos era así:

Python
# What the schema enforced: source_api is a string. It was a beautiful string.
assert isinstance(doc["provenance"]["source_api"], str)

El campo que el producto entero existe para garantizar, ¿cómo lo sabe?, estaba falso en veintisiete documentos, y todas las puertas estaban en verde, porque la puerta comprobaba la forma de la afirmación y nunca su verdad.

La corrección ata la afirmación a la realidad. Ahora existe una puerta que lee qué caminos de recogida declara realmente el recolector, y rechaza cualquier source_api que no sea uno de ellos:

Python
def test_no_fixture_claims_an_api_the_collector_never_uses():
    # The paths the collector really declares, read from its own source.
    paths = set()
    for f in [ORCHESTRATOR, *MODULES.glob("*.psm1")]:
        paths |= set(re.findall(r"-SourceApi\s+'([^']+)'", f.read_text()))
    # Every published source_api must be one the collector can produce.
    bad = [p.name for p in FIXTURES.rglob("*.json")
           if (api := load(p)["provenance"].get("source_api")) and api not in paths]
    assert not bad, f"fixtures claim an API the collector never uses: {bad}"

El primer borrador de la propia puerta estaba equivocado: leía solo los módulos y se saltaba el orquestador, y por eso acusaba a evidencia correcta de administración de mentir. Ese error está preservado en el historial a propósito. Un verificador que ya estuvo equivocado puede volver a estarlo, y recordar exactamente cómo es el seguro más barato que existe.

Forma tres: la marca de tiempo que hizo pasar una construcción por lectura

La más sutil. Nuestro resultado público de ejemplo mostraba una regla real fallando contra una biblioteca de documentos, sellada Collected: 2026-08-05T14:02:11Z. La lógica de la regla era correcta. El número era correcto. La biblioteca nunca existió: la evidencia era una fixture, hecha a mano para ejercitar un camino de código, con una marca de recogida porque el renderizador imprimía una para cada resultado.

Nada en aquella página era falso frase por frase. Ensamblada, afirmaba algo falso: que aquello se había observado en un tenant. La distancia entre un resultado verdadero sobre una construcción y la observación de un tenant es toda la distancia entre una demostración y un hallazgo, y la página la había borrado.

La reparación es un registro que vive fuera de los documentos de evidencia y clasifica cada fixture por su origen:

JSON
{
  "path": "fixtures/sharepoint/list-over-limit.json",
  "origin": "synthetic",
  "may_be_presented_as_tenant_observation": false
}

Y un test que rechaza que el esquema de evidencia aprenda jamás la palabra que colapsaría la distinción:

Python
def test_the_evidence_schema_knows_nothing_about_fixtures():
    # `acquisition` says how REAL evidence arrived: collected | imported.
    # Teaching it `synthetic` would let a production Assessment validate a
    # construction. The classification lives in the registry, not the schema.
    assert "synthetic" not in json.dumps(load(EVIDENCE_SCHEMA))

Dos preguntas, dos casas: lo que la evidencia es vive en el esquema; para qué sirve un archivo vive en el registro. El día en que un documento construido pueda validar como evidencia recogida, la diferencia desaparece para todos.

Por qué la respuesta equivocada y segura es la peor

Tres formas, una raíz. En cada caso, un sistema prefirió producir una respuesta antes que admitir los límites de lo que sabía. El enum tenía un defecto, así que respondió. La cadena validaba, así que pasó. La marca existía, así que se leyó como observada.

La disciplina que atrapa las tres es incómoda, porque hace que los informes parezcan peores antes de hacerlos honestos: unknown es un resultado válido, y es el honesto siempre que la evidencia no se haya leído de verdad. El motor tiene seis desenlaces, y dos de ellos describen la maquinaria y no el tenant:

Text
pass  fail  not-applicable  unknown  invalid-evidence  error

unknown quiere decir que la evidencia no estaba ahí para leerse: recoja otra vez, con el acceso que le faltaba. Se resuelve por un orden fijo que ningún motor es libre de reordenar: un camino obligatorio en estado missing, not-supported o permission-denied fuerza unknown antes de poder llegar a cualquier pass o fail. Un sitio cuyo SharingCapability vino de una enumeración no está en conformidad ni en violación. Está sin leer, y el informe tiene que decirlo.

Ahora pese los dos fallos. La evidencia ausente le dice dónde mirar: "16 sitios ilegibles con esta identidad" es una lista con nombre y un siguiente paso. La respuesta equivocada y segura le dice que deje de mirar. Gasta su presupuesto de atención en los sitios que menos lo necesitan, mientras el sitio que reporta Disabled reparte enlaces anónimos a quien los pida, en silencio.

Si se queda con un hábito de este texto, que sea este: para cada valor de sus paneles, siga el camino de lectura y pregunte qué devuelve cuando no puede rellenar el campo. Si la respuesta es cualquier cosa distinta de un rechazo visible, no tiene una laguna de monitorización. Tiene una máquina de confianza, y está apuntada a las cosas que menos le preocupan.

Etiquetas#governance#security#sharepoint#evidence

Comentarios