
Read-only Entra ID app-credential assessment: enumerates Graph permissions, Azure RBAC, and reachable cloud data, then maps findings to privilege-escalation and lateral-movement paths.
# secret_stalker
```
____ __ ____ __ ____
/ __/__ ______ ___ ___ / /_ / __/ / /____ _ / / /_____ ____
_\ \/ -_) __/ -_) -_)__/ __/ _\ \ / __/ _ `// / '_/ -_) __/
/___/\__/\__/\__/\__/ \__/ /___/ \__/\_,_//_/_/\_\\__/_/
╔╦╦╬╬╬╬╬╬╦╦╗
╔╬╬╬╝╝┘ ╚╝╝╬╬╬┐
╬╬╝╚╩╬╗╔ ╚╬╬╬
╬╝ ╚╬╬╗╗ ╔ ╚╬╗
╬╬ ╔╗ ╚╬╬╬╬╬╬╦ ╬╬ 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. \\
```
**What can this Entra ID client ID + secret actually do?**
You found an Entra ID (Azure AD) application credential — a client ID and secret —
on an authorized engagement, and the tenant it belongs to is in scope.
`secret_stalker` takes those two values and tells you, from a cold start:
1. **Is it valid, and when does the secret expire?** — and if not valid, *why*
(bad secret, expired secret, app not in the tenant…). For a valid secret it
reads the app registration's `passwordCredentials` and reports the expiration
date + days remaining (needs directory read; see note below).
2. **What Microsoft Graph rights does it carry?** — application permissions read
straight from the issued token, plus **Entra directory roles** it holds (even
detected passively from the token's `wids` claim) and **objects it owns**
(apps/SPs you can add credentials to).
3. **What control does it have over Azure?** — RBAC role assignments at
management-group and subscription scope.
4. **Can it reach real data?** — optional Key Vault (secrets / keys / certificates),
Storage (blob / file / queue / table), and Cosmos DB data-plane reachability checks.
5. **What's the impact?** — dangerous permissions, roles, ownership, and reachable
data mapped to known privesc / lateral-movement primitives, rated by severity,
with concrete **attack-path** narratives.
It authenticates with either a **client secret** or a **certificate** (`--cert`),
and works against **commercial and sovereign clouds** (`--cloud`).
It is **passive by default** and **never modifies anything** — read-only
enumeration only.
> ⚠️ **Authorized testing only.** Run it solely against tenants that are
> explicitly in scope for an engagement you are authorized to perform.
---
## 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
```
The only runtime dependency is `requests`. Tokens are decoded locally (base64 +
JSON) — no signature verification, no crypto library, no Microsoft SDK. The one
exception is certificate auth (`--cert`), which needs the optional `cryptography`
package to sign the JWT client assertion. Requires Python 3.7+.
After `pip install .` you can invoke it as `secret_stalker …` instead of
`python -m secret_stalker …`.
---
## Quickstart
The fastest way to find out what a credential can do:
```bash
python -m secret_stalker \
--tenant contoso.onmicrosoft.com \
--client-id 11111111-2222-3333-4444-555555555555 \
--secret '<client-secret>'
```
`--tenant` accepts either a tenant GUID or a domain — a domain is resolved to its
tenant ID automatically via the public OpenID configuration endpoint.
### Keep the secret out of your shell history
Pass credentials via environment variables instead of 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
```
Any of `--tenant` / `--client-id` / `--secret` can come from `SS_TENANT` /
`SS_CLIENT_ID` / `SS_SECRET`. Flags take precedence over the environment.
This isn't only about shell history: an argv value is readable by **any local
user** for the lifetime of the process (`ps`, `/proc/<pid>/cmdline`). If `--secret`
or `--cert-password` is passed as a flag, the tool prints a one-line reminder to
stderr — it never appears in `--json` or `--export` output.
### Authenticate with a certificate instead of a secret
App registrations often use a certificate rather than a secret. Pass `--cert`
(a PEM holding the private key **and** certificate, or a `.pfx`/`.p12`) and the
tool authenticates with a signed JWT client assertion:
```bash
python -m secret_stalker --tenant contoso.onmicrosoft.com \
--client-id <appId> --cert ./app.pem # or app.pfx
# encrypted key / PFX:
python -m secret_stalker ... --cert app.pfx --cert-password '<pw>'
```
Certificate auth needs the optional `cryptography` package (`pip install '.[cert]'`).
The tool reports the certificate's own expiry (matched on its thumbprint in the
app's `keyCredentials`), just like it does for a secret. `--cert`/`--cert-password`
also read from `SS_CERT` / `SS_CERT_PASSWORD`.
### Sovereign and government clouds
By default secret_stalker targets the **commercial** cloud. For sovereign tenants,
pass `--cloud` (or `SS_CLOUD`) so the Entra authority and the Graph / ARM / Key
Vault endpoints match — otherwise valid credentials look like they have no access:
```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 ...
```
| `--cloud` | Entra authority | Microsoft Graph | ARM | Key Vault |
|-----------|-----------------|-----------------|-----|-----------|
| `public` (default) | `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` |
Aliases like `gov`, `dod`, `commercial`, `gcc-high`, and `21vianet` are accepted.
(The Storage data-plane audience, `storage.azure.com`, is the same in every cloud.)
---
## Recommended workflow on an engagement
1. **Refresh the permission map for this tenant (once).** Resolves Graph
permission GUIDs to authoritative names for the target tenant:
```bash
python -m secret_stalker --update-manifest
```
This authenticates, caches the tenant's Graph appRole map to
`~/.secret_stalker/app_roles_cache.json`, and exits. Skip it if the
credential can't read service principals — the bundled map still covers the
well-known permissions.
2. **Passive baseline.** See validity, Graph permissions, and Azure RBAC without
touching tenant objects:
```bash
python -m secret_stalker
```
3. **Go active when you want depth + a report to keep.** Adds read-only Graph
object samples and Key Vault / Storage data-plane reachability, and writes a
flattened export for triage or ingestion:
```bash
python -m secret_stalker --active --export results.ndjson
```
---
## Reading the output
The terminal report is organized top-to-bottom from "does it work" to "what's the
damage." A typical run looks like:
```
secret_stalker — credential assessment
======================================
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)
Token acquisition
=================
OK graph
OK arm
NO storage — no storage token
Microsoft Graph application permissions (from token)
====================================================
- Application.ReadWrite.All
- Mail.Read
...
Findings — escalation / control
===============================
[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
```
- **Token acquisition** lists each audience probed (Graph, ARM, and — under
`--active`, when matching resources are discovered — Key Vault / Storage /
Cosmos DB). Graph and ARM are *independent*: a credential can hold one and not
the other.
- **Secret** shows validity and, for a valid secret, the expiration date and days
remaining (near-expiry is highlighted). See the note below on expired secrets.
- **Findings** is the part to read first — high-impact Graph permissions (`GRAPH`),
ARM roles (`ARM`), Entra directory roles (`ROLE`), owned apps/SPs (`OWN`),
reachable data-plane surfaces (`DATA`), and requested-but-unconsented
consent-attack targets (`WANT`) — de-duplicated and severity-rated. Being able to
read every Key Vault secret, or holding a directory role, is a finding on its own
even with no dangerous Graph/ARM grant.
- **Attack paths** turns the top findings into concrete next steps (e.g. *Privileged
Role Administrator → assign Global Administrator to self → tenant takeover*).
- **Active Graph enumeration** (`--active`) reports what each read-only probe
returned. Most probes request a small capped page, so a filled page is shown as
`N+` (e.g. `users accessible (returned 5+)`) — meaning *at least* five, not
exactly five. Probes with no cap (`organization`, `directoryRoles`) report a
real total with no `+`.
- **Directory roles / Owned objects / Delegated permissions** get their own sections.
Directory roles are detected from the token's `wids` claim even without directory
read; delegated permissions aren't usable by an app-only credential but are shown
for user-context pivots and consent-attack targeting.
- **Overall risk** is the highest single finding's severity.
> **Secret expiration — what's knowable.** The expiry date is *not* in the token;
> it lives on the app registration's `passwordCredentials` in Entra ID. For a
> **valid** secret, secret_stalker reads it via Graph and matches your secret to
> the right credential by its `hint` (first 3 chars) — this needs directory read
> (`Application.Read.All` / `Directory.Read.All`); if the SP lacks it, the date is
> reported as unavailable rather than guessed. For an **expired** secret the auth
> itself fails, so the dead credential cannot read its own metadata — the tool
> flags it `EXPIRED (AADSTS7000222)` but the exact end date isn't retrievable
> through that credential alone.
### Exit codes
Useful for scripting:
| Code | Meaning |
|------|---------|
| `0` | Credential is valid (got at least one token). |
| `2` | Credential is invalid / has no access. |
| `1` | Error — tenant could not be resolved, certificate could not be loaded, or the `--export` file could not be written. |
---
## All flags
| Flag | Effect |
|------|--------|
| `--tenant` | Tenant GUID or domain. (or `SS_TENANT`) |
| `--cloud` | Azure cloud: `public` (default), `usgov` (GCC High), `usdod` (DoD), `china` (21Vianet). Selects the Entra authority and Graph/ARM/Key Vault endpoints. Aliases like `gov`/`dod`/`commercial` accepted. (or `SS_CLOUD`) |
| `--client-id` | Application (client) ID. (or `SS_CLIENT_ID`) |
| `--secret` | Client secret. Prefer `SS_SECRET` to keep it out of history. |
| `--cert` | Certificate for JWT-assertion auth instead of a secret: a PEM (key+cert) or `.pfx`/`.p12`. Needs `cryptography`. (or `SS_CERT`) |
| `--cert-password` | Password for an encrypted `--cert` key/PFX. (or `SS_CERT_PASSWORD`) |
| `--active` | Opt-in read-only enumeration: Graph object samples **plus** Key Vault / Storage data-plane reachability. Off by default to stay quiet. |
| `--deep` | With `--active`: descend one level into reachable Storage — list blobs in accessible containers and files in accessible shares (names only, capped). Noisier. |
| `--no-arm` | Skip management-group / subscription / RBAC enumeration (Graph only). |
| `--workers N` | Parallel HTTP workers for ARM scope lookups and data-plane probes (default 8; `1` = sequential). |
| `--update-manifest` | Fetch the authoritative appRole GUID→name map from the live tenant (Graph **plus** any other resource APIs this credential is assigned on), cache it, then exit. |
| `--json` | Print the full nested result as JSON instead of the report. |
| `--export PATH` | Write results to a file. Format inferred from extension (`.csv` / `.ndjson` / `.jsonl` / `.json` / `.html`). Files are written owner-only (`0600`). |
| `--export-format` | Force the export format (`ndjson` / `csv` / `json` / `html`). |
| `--timeout N` | Per-request timeout in seconds (default 20). ARM control-plane requests (RBAC enumeration + Resource Graph discovery) use a longer timeout — `1.5×`, minimum 30s — because they run slower. |
| `--verbose`, `-v` | Trace every Graph/ARM/data-plane HTTP request (method, URL, status) to stderr. |
| `--no-banner` | Suppress the ASCII banner. |
| `--version` | Print version and exit. |
---
## Exporting results
`--export` flattens the result into **one record per discovered thing** —
credential, token, Graph permission, app-role assignment, ARM role, data-plane
hit, and scored finding — each carrying the credential context so a row stands on
its own.
```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
```
Every record carries a `record_type` (`credential`, `secret`, `token`,
`graph_permission`, `app_role_assignment`, `directory_role`, `owned_object`,
`arm_role`, `dataplane`, `delegated_permission`, `requested_permission`,
`finding`), so a consumer can filter to just what it needs — for example, the
scored hits only:
```bash
jq 'select(.record_type=="finding")' results.ndjson
```
The terminal report and `--export` work together — exporting doesn't suppress the
report (the "Exported …" confirmation goes to stderr, so piping `--json` stays
clean).
Export files carry credential context (token claims, the secret `hint`, key IDs),
so they are written **owner-only (`0600`)** to avoid leaking on a shared or synced
host. Treat them as sensitive engagement artifacts. Writing through a symlink is
refused outright, so an export path can't be redirected into truncating something
else.
Names in a result come from the tenant under assessment — app and group display
names, container and blob names — so they are treated as untrusted output:
- **CSV** values that would be read as a formula (leading `=`, `+`, `-`, `@`) are
prefixed with a single quote, so a display name like `=cmd|' /C calc'!A0` can't
execute when the file is opened in a spreadsheet. Spreadsheets strip the quote
on display.
- **Terminal, CSV and HTML** output has control characters stripped, so a name
carrying ANSI escapes can't retitle your terminal or overwrite the findings
above it — whether you read the report live, `cat` the CSV, or `cat` the HTML.
- **JSON / NDJSON are left faithful**: `json.dumps` encodes control characters as
`\uXXXX`, which is inert as text while a parser still round-trips the exact
value the tenant returned. The raw name is evidence, so it is preserved there.
---
## How permission GUIDs are resolved
`appRoleAssignments` come back as GUIDs. secret_stalker resolves them to names by
a flat lookup (appRole GUIDs are globally unique), which keeps working **even when
directory reads are denied**:
- A best-effort map of well-known Graph permissions ships in
`secret_stalker/data/graph_app_roles.json`.
- `--update-manifest` overrides it with authoritative data pulled live from the
in-scope tenant — Microsoft Graph **plus every other resource API this credential
is assigned on** (e.g. Exchange Online, SharePoint), so non-Graph GUIDs resolve too.
- An unknown GUID is shown **raw and flagged** — the tool never guesses a name.
---
## How it works (the short version)
- **Validity + permissions in one request.** A successful Graph token's `roles`
claim *is* the list of granted application permissions. secret_stalker reads it
from the decoded token — fast and quiet, no Graph calls needed.
- **Graph ≠ ARM.** They are different token audiences. A credential can hold rights
on one and not the other, so each is probed independently.
- **Data plane ≠ control plane.** Having ARM rights over a Key Vault (management)
is not the same as being able to read its secrets (data plane). Under `--active`,
data-plane reachability is tested with the resource's own token audience — and it
lists object **names only**, never values or contents.
- **Per-surface data-plane probing.** Data-plane RBAC is granted per object type /
service, so each is probed independently: Key Vault **secrets / keys /
certificates**, Storage **blob / file / queue / table**, and Cosmos DB
**databases**. A credential that's `Storage File Data SMB Share Reader` but not a
blob reader is surfaced, not missed. (Cosmos uses a non-standard AAD REST header
and is **best-effort** — validate a `denied` result against a live account.)
- **Tenant-wide discovery.** Resources are found with a single Azure Resource Graph
sweep across every subscription the principal can see (RBAC-respecting), falling
back to per-subscription provider listing if ARG is denied. The report tags which
path was used (`[discovery: resource-graph]` vs `per-subscription`). The sweep
pages through results up to a bound (40 pages × 1000 rows per resource type) so a
run always terminates; `--verbose` says so if the bound is ever reached.
- **Severity mapping** lives in `secret_stalker/risk.py` — edit it to tune what
your team treats as high-impact.
---
## Project layout
```
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
```
---
## Implemented highlights
- **Secret or certificate auth** (`--cert`) — JWT client-assertion (RS256) from a
PEM or PFX, with certificate-expiry reporting. `auth.py`
- **Entra directory roles** held by the principal — including passive detection
from the token's `wids` claim (no directory read needed) — scored by role. `graph.py` / `risk.py`
- **Owned objects** — apps/SPs the credential owns (a credential-add pivot). `graph.py`
- **Delegated-permission visibility** (`--active`) — consented grants + requested
permissions, with unconsented dangerous perms flagged as consent-attack targets. `graph.py` / `risk.py`
- **Attack-path narratives** + **HTML report** (`--export report.html`). `risk.py` / `report.py`
- **Commercial + sovereign clouds** (`--cloud`) — public, US Gov (GCC High), US DoD,
and China (21Vianet), each with the correct Entra authority and Graph / ARM / Key
Vault audiences. `clouds.py`
- **Tenant-wide resource discovery** via a single Azure Resource Graph sweep
(RBAC-respecting), with per-subscription fallback and management-group descendant
recovery for subscriptions the flat list omits. `arm.py`
- **Full data-plane coverage** — Key Vault secrets / keys / certificates, Storage
blob / file / queue / table, and Cosmos DB databases, each probed independently
and folded into the scored findings (reachable data is a finding, not just
metadata). `dataplane.py` / `risk.py`
- **Accurate counts** — data-plane listings follow continuation tokens (bounded), so
a surface count reflects the real total; a `+` (e.g. `25+`) marks where the listing
was capped rather than silently under-reporting. `dataplane.py`
- **Throttling-resilient requests** — one shared HTTP path retries `429`/`503`
honoring `Retry-After`, so transient throttling isn't misread as "denied / no
access." `util.py`
- **Deep Storage descent** (`--deep`) — lists blobs in reachable containers and
files in reachable shares, names only and capped. `dataplane.py`
- **Non-Graph GUID resolution** — `--update-manifest` caches appRoles for every
resource API the credential is assigned on, not just Graph. `graph.py` / `manifest.py`
- **Concurrency** (`--workers N`) across ARM scope lookups and data-plane probes,
with per-item error isolation. `util.py`
## Roadmap ideas
- Key Vault keys/certificates *attribute* read (enabled/expiry) for triage —
still metadata only, no key material.
- Storage queue/table content peeking would cross into reading data, so it is
intentionally **out of scope** for this read-only tool.