
Evaluación de solo lectura de credenciales de aplicaciones de Entra ID: enumera los permisos de Graph, el RBAC de Azure y los datos de nube accesibles, y mapea los hallazgos con rutas de escalada de privilegios y movimiento lateral.
/ / ______ ___ ___ / /_ / / / /__ _ / / /_____ ____ \ / -) / -) -)/ / \ \ / __/ _ `// / '/ -) / //_/_/_/_/ _/ // _/_,////_\__/_/
╔╦╦╬╬╬╬╬╬╦╦╗
╔╬╬╬╝╝┘ ╚╝╝╬╬╬┐
╬╬╝╚╩╬╗╔ ╚╬╬╬
╬╝ ╚╬╬╗╗ ╔ ╚╬╗ ╬╬ ╔╗ ╚╬╬╬╬╬╬╦ ╬╬ we found your secret... ╔╬┤ ╬╬╬ ╬╬╬╬╬╬╬╬╝╝╝╬╬╗ ...now let's see what it ╬╬┤ ╚╩┘ ╚╬╬╬╬╬╩ ╠╬╬ can REALLY do. ( o_o)>=|= ╬╬┤ ╠╬╬ ╬╬ ╦╗ ╗╗ ╬╬ [ client_id + secret -> total recall ] └╬┐ ╚╬╗╗ ╔╬╬╝ ╔╬┘ └╬╗ ╚╩╩╬╬╬╩╩╝╝ ╔╬╬ ╚╬╬╬╗ ┌╗╬╬╝┘ ╚╩╬╬╬╦╦╦╦╦╦╬╬╬╝╝ ╚╚╝╝╝╝ // pst... that app registration talks too much. \
**¿Qué puede hacer realmente este ID de cliente + secreto de Entra ID?**
Has encontrado una credencial de aplicación de Entra ID (Azure AD) — un ID de cliente y un secreto —
en un compromiso autorizado, y el tenant al que pertenece está dentro del alcance.
`secret_stalker` toma esos dos valores y te dice, desde cero:
1. **¿Es válido y cuándo expira el secreto?** — y si no es válido, *por qué*
(secreto incorrecto, secreto caducado, aplicación no presente en el tenant…). Para un secreto válido,
lee los `passwordCredentials` del registro de la aplicación e informa de la
fecha de expiración + días restantes (necesita lectura de directorio; ver nota más abajo).
2. **¿Qué derechos de Microsoft Graph tiene?** — permisos de aplicación leídos
directamente del token emitido, más los **roles de directorio de Entra** que posee (incluso
detectados pasivamente desde el claim `wids` del token) y **objetos que son de su propiedad**
(apps/SPs a los que puedes añadir credenciales).
3. **¿Qué control tiene sobre Azure?** — asignaciones de roles RBAC a nivel de
grupo de administración y ámbito de suscripción.
4. **¿Puede acceder a datos reales?** — comprobaciones opcionales de Key Vault (secretos / claves / certificados),
Storage (blob / file / queue / table) y comprobaciones de alcance en el plano de datos de Cosmos DB.
5. **¿Cuál es el impacto?** — permisos peligrosos, roles, propiedad y datos
accesibles asociados a primitivas conocidas de privesc / movimiento lateral, clasificados por severidad,
con narrativas concretas de **rutas de ataque**.
Se autentica con un **secreto de cliente** o un **certificado** (`--cert`),
y funciona con **nubes comerciales y soberanas** (`--cloud`).
Es **pasivo por defecto** y **nunca modifica nada** — únicamente
enumeración de solo lectura.
> ⚠️ **Solo pruebas autorizadas.** Ejecútalo únicamente contra tenants que estén
> explícitamente dentro del alcance de un compromiso que estés autorizado a realizar.
---
## Install```bash
pip install -r requirements.txt # just runs it from source
# — or —
pip install . # installs the `secret_stalker` command
pip install '.[cert]' # + certificate (--cert) auth support
pip install '.[dev]' # + pytest for the test suite
La única dependencia de ejecución es requests. Los tokens se decodifican localmente (base64 +
JSON): no hay verificación de firmas, ni biblioteca criptográfica, ni SDK de Microsoft. La única
excepción es la autenticación por certificado (--cert), que necesita el paquete opcional cryptography
para firmar la aserción de cliente JWT. Requiere Python 3.7+.
Después de pip install . puedes invocarlo como secret_stalker … en lugar de
python -m secret_stalker ….
La forma más rápida de descubrir qué puede hacer una credencial:```bash
python -m secret_stalker
--tenant contoso.onmicrosoft.com
--client-id 11111111-2222-3333-4444-555555555555
--secret ''
`--tenant` acepta un GUID de inquilino o un dominio — un dominio se resuelve a su
ID de inquilino automáticamente mediante el endpoint público de configuración de OpenID.
### Mantén el secreto fuera del historial de tu shell
Pasa las credenciales mediante variables de entorno en lugar de flags:```bash
export SS_TENANT=contoso.onmicrosoft.com
export SS_CLIENT_ID=11111111-2222-3333-4444-555555555555
export SS_SECRET='<client-secret>'
python -m secret_stalker
Cualquiera de --tenant / --client-id / --secret puede provenir de SS_TENANT /
SS_CLIENT_ID / SS_SECRET. Las opciones tienen prioridad sobre el entorno.
Esto no solo se trata del historial del shell: un valor de argv es legible por cualquier
usuario local durante toda la vida del proceso (ps, /proc/<pid>/cmdline). Si --secret
o --cert-password se pasa como una opción, la herramienta imprime un recordatorio de una línea en
stderr — nunca aparece en la salida de --json o --export.
Los registros de aplicaciones suelen usar un certificado en lugar de un secreto. Pasa --cert
(un PEM que contiene la clave privada y el certificado, o un .pfx/.p12) y la
herramienta autentica con una aserción de cliente JWT firmada:```bash
python -m secret_stalker --tenant contoso.onmicrosoft.com
--client-id --cert ./app.pem # or app.pfx
python -m secret_stalker ... --cert app.pfx --cert-password ''
La autenticación con certificado requiere el paquete opcional `cryptography` (`pip install '.[cert]'`).
La herramienta informa la caducidad del propio certificado (coincidiendo con su huella digital en
`keyCredentials` de la aplicación), igual que hace con un secreto. `--cert`/`--cert-password`
también se leen de `SS_CERT` / `SS_CERT_PASSWORD`.
### Nubes soberanas y gubernamentales
Por defecto, secret_stalker se dirige a la nube **comercial**. Para inquilinos soberanos,
pasa `--cloud` (o `SS_CLOUD`) para que la autoridad de Entra y los endpoints de Graph / ARM / Key
Vault coincidan; de lo contrario, las credenciales válidas parecen no tener acceso:```bash
# US Government (GCC High)
python -m secret_stalker --cloud usgov --tenant contoso.onmicrosoft.us ...
# US DoD (L5)
python -m secret_stalker --cloud usdod ...
# Azure operated by 21Vianet (China)
python -m secret_stalker --cloud china --tenant contoso.partner.onmschina.cn ...
Se aceptan alias como gov, dod, commercial, gcc-high y 21vianet.
(La audiencia del plano de datos de Storage, storage.azure.com, es la misma en todas las nubes.)
Esto autentica, guarda en caché el mapa de appRoles de Graph del inquilino en
~/.secret_stalker/app_roles_cache.json y sale. Omítelo si la
credencial no puede leer los service principals; el mapa incluido sigue cubriendo los
permisos conocidos.
Credential status : VALID Tenant : aaaaaaaa-... Client (app) id : 1111... App display name : Recon App SP object id : cccc... Secret : valid — expires 2027-03-01 (in 207 days)
OK graph OK arm NO storage — no storage token
...
[CRITICAL] (GRAPH) Application.ReadWrite.All Can add credentials to any app/SP and impersonate it — tenant-wide pivot. [CRITICAL] (ARM) Owner Full control including granting access to others. [CRITICAL] (DATA) keyvault:secrets Can read Key Vault secret values — connection strings, passwords, tokens. [MEDIUM] (GRAPH) Mail.Read Read all mailboxes — data exposure.
Overall risk: CRITICAL
- **Adquisición de tokens** enumera cada audiencia sondeada (Graph, ARM y — con
`--active`, cuando se descubren recursos coincidentes — Key Vault / Storage /
Cosmos DB). Graph y ARM son *independientes*: una credencial puede tener uno y no
el otro.
- **Secreto** muestra la validez y, para un secreto válido, la fecha de caducidad y los días
restantes (la proximidad a caducar se resalta). Véase la nota más abajo sobre
secretos caducados.
- **Hallazgos** es la sección que debe leerse primero — permisos de Graph de alto impacto (`GRAPH`),
roles de ARM (`ARM`), roles de directorio de Entra (`ROLE`), apps/SPs propios (`OWN`),
superficies de plano de datos alcanzables (`DATA`) y objetivos de ataque de consentimiento
solicitados pero no consentidos (`WANT`) — deduplicados y clasificados por severidad. Poder
leer todos los secretos de Key Vault, o tener un rol de directorio, es un hallazgo en sí mismo
incluso sin ninguna concesión peligrosa de Graph/ARM.
- **Rutas de ataque** convierten los principales hallazgos en pasos concretos (p. ej. *Administrador
de roles con privilegios → asignarse Administrador global a uno mismo → toma de control del inquilino*).
- **Enumeración activa de Graph** (`--active`) informa lo que devolvió cada sonda de solo lectura.
La mayoría de las sondas solicitan una página pequeña con límite, por lo que una página llena se
muestra como `N+` (p. ej. `users accessible (returned 5+)`) — es decir, *al menos* cinco, no
exactamente cinco. Las sondas sin límite (`organization`, `directoryRoles`) informan un total real
sin `+`.
- **Roles de directorio / Objetos propios / Permisos delegados** tienen sus propias secciones. Los
roles de directorio se detectan a partir de la reclamación `wids` del token incluso sin lectura de
directorio; los permisos delegados no son utilizables por una credencial solo de aplicación, pero
se muestran para pivotes de contexto de usuario y para apuntar ataques de consentimiento.
- **Riesgo general** es la severidad del hallazgo individual más alto.
> **Caducidad del secreto: lo que se puede saber.** La fecha de caducidad *no* está en el token;
> vive en los `passwordCredentials` del registro de la aplicación en Entra ID. Para un
> secreto **válido**, secret_stalker lo lee a través de Graph y hace coincidir tu secreto con
> la credencial correcta mediante su `hint` (primeros 3 caracteres) — esto requiere lectura de directorio
> (`Application.Read.All` / `Directory.Read.All`); si el SP no la tiene, la fecha se
> informa como no disponible en lugar de adivinarla. Para un secreto **caducado**, la autenticación
> en sí falla, por lo que la credencial muerta no puede leer sus propios metadatos — la herramienta
> lo marca como `EXPIRED (AADSTS7000222)`, pero la fecha exacta de fin no se puede recuperar
> solo a través de esa credencial.
### Códigos de salida
Útil para scripting:
| Código | Significado |
|------|---------|
| `0` | La credencial es válida (se obtuvo al menos un token). |
| `2` | La credencial no es válida / no tiene acceso. |
| `1` | Error — no se pudo resolver el inquilino, no se pudo cargar el certificado o no se pudo escribir el archivo `--export`. |
---
## Todas las opciones
| Opción | Efecto |
|------|--------|
| `--tenant` | GUID o dominio del inquilino. (o `SS_TENANT`) |
| `--cloud` | Nube de Azure: `public` (predeterminada), `usgov` (GCC High), `usdod` (DoD), `china` (21Vianet). Selecciona la autoridad de Entra y los endpoints de Graph/ARM/Key Vault. Se aceptan alias como `gov`/`dod`/`commercial`. (o `SS_CLOUD`) |
| `--client-id` | ID de aplicación (cliente). (o `SS_CLIENT_ID`) |
| `--secret` | Secreto de cliente. Prefiere `SS_SECRET` para mantenerlo fuera del historial. |
| `--cert` | Certificado para autenticación mediante aserción JWT en lugar de un secreto: un PEM (clave+cert) o `.pfx`/`.p12`. Requiere `cryptography`. (o `SS_CERT`) |
| `--cert-password` | Contraseña para una clave/PFX `--cert` cifrada. (o `SS_CERT_PASSWORD`) |
| `--active` | Enumeración de solo lectura opcional: muestras de objetos de Graph **más** alcanzabilidad del plano de datos de Key Vault / Storage. Desactivada por defecto para pasar desapercibido. |
| `--deep` | Con `--active`: desciende un nivel en Storage alcanzable — lista blobs en contenedores accesibles y archivos en recursos compartidos accesibles (solo nombres, con límite). Más ruidoso. |
| `--no-arm` | Omite la enumeración de grupos de administración / suscripciones / RBAC (solo Graph). |
| `--workers N` | Trabajadores HTTP paralelos para búsquedas de ámbito ARM y sondas de plano de datos (predeterminado 8; `1` = secuencial). |
| `--update-manifest` | Obtiene el mapa autoritativo GUID→nombre de appRole del inquilino activo (Graph **más** cualquier otra API de recurso en la que esta credencial esté asignada), lo guarda en caché y sale. |
| `--json` | Imprime el resultado anidado completo como JSON en lugar del informe. |
| `--export PATH` | Escribe los resultados en un archivo. El formato se deduce de la extensión (`.csv` / `.ndjson` / `.jsonl` / `.json` / `.html`). Los archivos se escriben solo para el propietario (`0600`). |
| `--export-format` | Fuerza el formato de exportación (`ndjson` / `csv` / `json` / `html`). |
| `--timeout N` | Tiempo de espera por solicitud en segundos (predeterminado 20). Las solicitudes del plano de control de ARM (enumeración RBAC + descubrimiento de Resource Graph) usan un tiempo de espera mayor — `1.5×`, mínimo 30 s — porque son más lentas. |
| `--verbose`, `-v` | Traza cada solicitud HTTP de Graph/ARM/plano de datos (método, URL, estado) a stderr. |
| `--no-banner` | Suprime el banner ASCII. |
| `--version` | Imprime la versión y sale. |
---
## Exportación de resultados
`--export` aplana el resultado en **un registro por cosa descubierta** —
credencial, token, permiso de Graph, asignación de rol de aplicación, rol de ARM,
acceso al plano de datos y hallazgo puntuado — cada uno con el contexto de la credencial, de modo que una fila se sostiene
por sí misma.```bash
# NDJSON — stream into a SIEM / log pipeline
python -m secret_stalker --export results.ndjson
# CSV — open in a spreadsheet for triage
python -m secret_stalker --active --export results.csv
# Full nested JSON to a file
python -m secret_stalker --export results.json
Cada registro lleva un record_type (credential, secret, token,
graph_permission, app_role_assignment, directory_role, owned_object,
arm_role, dataplane, delegated_permission, requested_permission,
finding), de modo que un consumidor puede filtrar solo lo que necesita — por ejemplo, los
resultados puntuados solamente:```bash
jq 'select(.record_type=="finding")' results.ndjson
El informe de terminal y `--export` funcionan en conjunto — exportar no suprime el
informe (la confirmación "Exported …" va a stderr, por lo que canalizar `--json` sigue
limpio).
Los archivos de exportación llevan contexto de credenciales (claims del token, la pista secreta `hint`, IDs de clave),
por lo que se escriben **solo para el propietario (`0600`)** para evitar fugas en un host
compartido o sincronizado. Trátalos como artefactos sensibles de la evaluación. Escribir a través de un symlink
se rechaza de plano, por lo que una ruta de exportación no puede redirigirse para truncar otra
cosa.
Los nombres de un resultado provienen del tenant bajo evaluación — nombres para mostrar de aplicaciones y grupos,
nombres de contenedores y blobs — por lo que se tratan como salida no confiable:
- Los valores **CSV** que se leerían como fórmula (empiezan con `=`, `+`, `-`, `@`) se
les antepone una comilla simple, de modo que un nombre para mostrar como `=cmd|' /C calc'!A0` no puede
ejecutarse cuando el archivo se abre en una hoja de cálculo. Las hojas de cálculo eliminan la comilla
al mostrarlo.
- La salida de **Terminal, CSV y HTML** tiene caracteres de control eliminados, de modo que un nombre
con escapes ANSI no puede cambiar el título de tu terminal ni sobrescribir los hallazgos
que están encima — ya sea que leas el informe en vivo, uses `cat` con el CSV o `cat` con el HTML.
- **JSON / NDJSON se conservan fieles**: `json.dumps` codifica los caracteres de control como
`\uXXXX`, que es inerte como texto mientras un parser sigue recuperando el valor exacto
que devolvió el tenant. El nombre crudo es evidencia, por lo que se conserva allí.
---
## Cómo se resuelven los GUID de permisos
`appRoleAssignments` se devuelven como GUIDs. secret_stalker los resuelve a nombres mediante
una búsqueda plana (los GUID de appRole son únicos a nivel global), que sigue funcionando **incluso cuando
se deniega la lectura del directorio**:
- Un mapa de mejor esfuerzo de permisos Graph conocidos se incluye en
`secret_stalker/data/graph_app_roles.json`.
- `--update-manifest` lo sobrescribe con datos autoritativos obtenidos en vivo desde el
tenant en alcance — Microsoft Graph **más cualquier otra API de recurso en la que esta credencial
está asignada** (p. ej., Exchange Online, SharePoint), por lo que los GUID que no son de Graph también se resuelven.
- Un GUID desconocido se muestra **sin procesar y marcado** — la herramienta nunca adivina un nombre.
---
## Cómo funciona (la versión corta)
- **Validez + permisos en una sola solicitud.** El claim `roles` de un token Graph válido
*es* la lista de permisos de aplicación concedidos. secret_stalker lo lee
del token decodificado — rápido y silencioso, sin necesidad de llamadas a Graph.
- **Graph ≠ ARM.** Son audiencias de token diferentes. Una credencial puede tener derechos
en una y no en la otra, por lo que cada una se prueba de forma independiente.
- **Plano de datos ≠ plano de control.** Tener derechos ARM sobre un Key Vault (administración)
no es lo mismo que poder leer sus secretos (plano de datos). Con `--active`,
la alcanzabilidad del plano de datos se prueba con la audiencia de token propia del recurso — y
lista solo **nombres** de objetos, nunca valores ni contenidos.
- **Sondeo del plano de datos por superficie.** El RBAC del plano de datos se concede por tipo de objeto /
servicio, por lo que cada uno se prueba de forma independiente: Key Vault **secretos / claves /
certificados**, Storage **blob / archivo / cola / tabla**, y Cosmos DB
**bases de datos**. Una credencial que tiene `Storage File Data SMB Share Reader` pero no un
lector de blobs se detecta, no se pierde. (Cosmos usa un encabezado REST de AAD no estándar
y es de **mejor esfuerzo** — valida un resultado `denied` contra una cuenta en vivo.)
- **Descubrimiento en todo el tenant.** Los recursos se encuentran con un solo barrido de Azure Resource Graph
a través de cada suscripción que el principal puede ver (respetando RBAC), y si
ARG es denegado, se recurre al listado de proveedores por suscripción. El informe etiqueta qué
ruta se usó (`[discovery: resource-graph]` vs `per-subscription`). El barrido
pagina los resultados hasta un límite (40 páginas × 1000 filas por tipo de recurso), de modo que una
ejecución siempre termina; `--verbose` lo indica si el límite se alcanza.
- **El mapeo de severidad** vive en `secret_stalker/risk.py` — edítalo para ajustar qué
tu equipo considera de alto impacto.
---
## Estructura del proyecto```
secret_stalker/
clouds.py Azure cloud endpoint table (public / usgov / usdod / china)
auth.py client_credentials flow + tenant discovery + AADSTS decoding
jwt_utils.py local JWT claim extraction
manifest.py Graph appRole GUID -> name resolution (bundled + live cache)
graph.py service principal lookup + appRole resolution + active probes
arm.py management-group / subscription RBAC + resource discovery
dataplane.py Key Vault / Storage data-plane reachability probes
risk.py permission/role/data-plane -> impact mapping (tune this)
report.py terminal + JSON output
export.py flatten to NDJSON / CSV / JSON records for ingestion
util.py shared HTTP (retry/backoff + safe JSON), pmap parallel map,
untrusted-output sanitizing
banner.py ASCII banner (stderr only)
cli.py orchestration
data/graph_app_roles.json bundled permission manifest
tests/ pytest suite (run: pytest)
pyproject.toml packaging + `secret_stalker` console entry point
--cert) — aserción de cliente JWT (RS256) desde un
PEM o PFX, con informe de caducidad del certificado. auth.pywids del token (sin necesidad de lectura del directorio) — puntuados por rol. graph.py / risk.pygraph.py--active) — consentimientos concedidos + permisos
solicitados, con permisos peligrosos no consentidos marcados como objetivos de ataque de consentimiento. graph.py / risk.py--export report.html). risk.py / --cloud | Autoridad de Entra | Microsoft Graph | ARM | Key Vault |
|---|
public (predeterminado) | login.microsoftonline.com | graph.microsoft.com | management.azure.com | vault.azure.net |
usgov (GCC High) | login.microsoftonline.us | graph.microsoft.us | management.usgovcloudapi.net | vault.usgovcloudapi.net |
usdod (DoD) | login.microsoftonline.us | dod-graph.microsoft.us | management.usgovcloudapi.net | vault.usgovcloudapi.net |
china (21Vianet) | login.chinacloudapi.cn | microsoftgraph.chinacloudapi.cn | management.chinacloudapi.cn | vault.azure.cn |
report.py--cloud) — pública, US Gov (GCC High), US DoD
y China (21Vianet), cada una con la autoridad de Entra y las audiencias de Graph / ARM / Key
Vault correctas. clouds.pyarm.pydataplane.py / risk.py+ (p. ej. 25+) marca dónde el listado
se limitó en lugar de informar de menos en silencio. dataplane.py429/503
respetando Retry-After, de modo que la limitación transitoria no se malinterprete como "denegado / sin
acceso". util.py--deep) — lista blobs en contenedores accesibles y
archivos en recursos compartidos accesibles, solo nombres y con límite. dataplane.py--update-manifest almacena en caché appRoles para cada
API de recurso en la que la credencial está asignada, no solo Graph. graph.py / manifest.py--workers N) en las búsquedas de ámbito ARM y en los sondeos del plano de datos,
con aislamiento de errores por elemento. util.py