____ __ ____ __ ____
/ __/__ ______ ___ ___ / /_ / __/ / /____ _ / / /_____ ____
_\ \/ -_) __/ -_) -_)__/ __/ _\ \ / __/ _ `// / '_/ -_) __/
/___/\__/\__/\__/\__/ \__/ /___/ \__/\_,_//_/_/\_\\__/_/
╔╦╦╬╬╬╬╬╬╦╦╗
╔╬╬╬╝╝┘ ╚╝╝╬╬╬┐
╬╬╝╚╩╬╗╔ ╚╬╬╬
╬╝ ╚╬╬╗╗ ╔ ╚╬╗
╬╬ ╔╗ ╚╬╬╬╬╬╬╦ ╬╬ 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:
- 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
passwordCredentialsand reports the expiration date + days remaining (needs directory read; see note below). - 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
widsclaim) and objects it owns (apps/SPs you can add credentials to). - What control does it have over Azure? — RBAC role assignments at management-group and subscription scope.
- Can it reach real data? — optional Key Vault (secrets / keys / certificates), Storage (blob / file / queue / table), and Cosmos DB data-plane reachability checks.
- 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.
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 suiteThe 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 ….
The fastest way to find out what a credential can do:
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.
Pass credentials via environment variables instead of flags:
export SS_TENANT=contoso.onmicrosoft.com
export SS_CLIENT_ID=11111111-2222-3333-4444-555555555555
export SS_SECRET='<client-secret>'
python -m secret_stalkerAny 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.
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:
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.
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:
# 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.)
-
Refresh the permission map for this tenant (once). Resolves Graph permission GUIDs to authoritative names for the target tenant:
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. -
Passive baseline. See validity, Graph permissions, and Azure RBAC without touching tenant objects:
python -m secret_stalker
-
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:
python -m secret_stalker --active --export results.ndjson
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 asN+(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
widsclaim 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
passwordCredentialsin Entra ID. For a valid secret, secret_stalker reads it via Graph and matches your secret to the right credential by itshint(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 itEXPIRED (AADSTS7000222)but the exact end date isn't retrievable through that credential alone.
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. |
| 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. |
--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.
# 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.jsonEvery 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:
jq 'select(.record_type=="finding")' results.ndjsonThe 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'!A0can'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,
catthe CSV, orcatthe HTML. - JSON / NDJSON are left faithful:
json.dumpsencodes 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.
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-manifestoverrides 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.
- Validity + permissions in one request. A successful Graph token's
rolesclaim 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 Readerbut not a blob reader is surfaced, not missed. (Cosmos uses a non-standard AAD REST header and is best-effort — validate adeniedresult 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]vsper-subscription). The sweep pages through results up to a bound (40 pages × 1000 rows per resource type) so a run always terminates;--verbosesays 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.
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
- 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
widsclaim (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/503honoringRetry-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-manifestcaches 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
- 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.