Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

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:

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:

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:

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:

# 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:

    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:

    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:

    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.

# 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:

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.

About

No description, website, or topics provided.

Resources

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages