azath - Azure Attack-Path Analyzer
The kind of identity attack graph BloodHound builds — with the analysis it leaves to you.
azath reads an Azure / Entra tenant read-only, builds a typed graph of
identities, roles, and resources, finds privilege-escalation paths under an
assumed-breach model, and uses an LLM to explain and prioritize them in
plain English.
ℹ️ Independent tool.
azathbuilds its own graph directly from Microsoft Graph and Azure Resource Manager. It does not use, wrap, import from, or depend on BloodHound or AzureHound — they're named here only to place this tool in a landscape readers already know.
azath scan
┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Severity ┃ Finding ┃ ID ┃
┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━┩
│ CRITICAL │ Dana (contractor) can become 'build-pipeline-sp' and │ AZATH-CRED-8D676C70C5 │
│ │ reach Owner at subscription 0000…00ff │ │
│ CRITICAL │ Helpdesk Tier 1 can reach Owner at subscription │ AZATH-GRP-E6E22CFAA8 │
│ │ 0000…00ff via group 'Azure Subscription Admins' │ │
│ HIGH │ Compromise of 'lab-ingest-func' reaches Owner at │ AZATH-MI-A64C9FA465 │
│ │ resource group 'rg-lab' │ │
└──────────┴──────────────────────────────────────────────────────┴───────────────────────┘
Each finding carries the full path and the evidence behind every hop:
1. Helpdesk Tier 1 --[member_of]--> Helpdesk Operators
2. Helpdesk Operators --[member_of]--> Azure Subscription Admins
3. Azure Subscription Admins --[has_role]--> Owner
4. Owner --[assigned_at]--> subscription 0000…00ff
A helpdesk-tier user holding subscription Owner through two layers of group nesting — invisible to anyone reading the subscription's access list, because their name isn't on it.
📄 Sample report · 🕸️ Interactive graph demo — both generated from synthetic fixtures, so you can see real output without an Azure account.
The design thesis
The LLM is a reader and synthesizer of structured findings, never an actor.
Deterministic code gathers and structures the data; the LLM explains and prioritizes it. It never queries Azure, never decides what to collect, and cannot invent a finding — every claim traces back to typed evidence produced by static analysis. If the LLM is unavailable, the report still renders in full.
Why another tool?
| Tool class | Examples | Strength | The gap azath fills |
|---|---|---|---|
| Config-checklist auditors | ScoutSuite, Prowler | Broad CIS/CSPM coverage | Grade config, not reachability — they miss multi-hop paths |
| Raw graph tools | BloodHound, AzureHound | Powerful identity graphs | Require an expert to read; no prioritization or narrative |
azath isn't trying to out-breadth BloodHound or out-checklist Prowler. Its one
job is the "so what, and what do I fix first" that a raw graph or a
checklist leaves to you.
Threat model: assumed breach
It doesn't ask "does this config match a benchmark?" It asks:
"If an attacker already controls identity X, where can they get to, and how?"
Every finding is a concrete path from a starting principal to elevated privilege, with the exact roles and permissions that make each hop possible.
What it detects
| # | Path | Hops | Why it's easy to miss |
|---|---|---|---|
| 1 | roleAssignments/write self-escalation — Owner / User Access Administrator |
2 | Looks like normal admin access until you notice it's self-granting |
| 2 | Group-mediated — members through nested groups, and group owners | 4+ | The user's name never appears on the subscription's access list |
| 3 | Managed-identity abuse — a VM/Function/Automation Account whose identity is privileged | 3 | Code execution on the resource is authentication as the identity |
| 4 | App-credential addition — app ownership, Application.ReadWrite.All, or a credential-capable directory role |
3 | Runs through Entra, so an Azure access review structurally cannot see it |
| 5 | Hidden dangerous actions — runCommand, listKeys, Key Vault policy writes |
2 | Nobody reads a 40-entry actions array; they read "Storage Operations Helper" |
Severity tracks blast radius: the same role is CRITICAL at subscription
root, HIGH on a resource group, MEDIUM on a single resource.
Install
pip install azath
Requires Python 3.11+ and a read-only service principal. The full app-registration walkthrough — with why at each step — is in docs/AZURE_SETUP.md.
curl -O https://raw.githubusercontent.com/tomerblum/azath/main/.env.example
cp .env.example .env && chmod 600 .env # then fill in your values
azath config-check # confirm what's set
azath smoke-test # THE GATE: must pass before anything else
azath scan # -> reports/attack-paths.md
From source
git clone https://github.com/tomerblum/azath
cd azath
python3.11 -m venv .venv && source .venv/bin/activate
python --version # confirm 3.11+ before continuing
pip install --upgrade pip # editable installs need pip >= 21.3
pip install -e ".[dev]"
cp .env.example .env && chmod 600 .env # then fill in your values
azath smoke-test
macOS note:
python3is usually Apple's system Python 3.9 even afterbrew install python@3.11— Homebrew'spython@3.11is keg-only. Build the venv withpython3.11explicitly, or you'll hitERROR: File "setup.py" or "setup.cfg" not found(system pip 21.2.4 predates PEP 660 editable installs).
Commands
| Command | Purpose |
|---|---|
azath smoke-test |
Verify auth against both Microsoft Graph and Azure Resource Graph |
azath scan |
Collect → detect → explain → Markdown report |
azath scan --no-llm |
Deterministic findings only, no LLM call |
azath scan --json out.json |
Also export findings + snapshot as JSON |
azath diff old.json new.json |
What's new, resolved, or re-scored since a previous scan |
azath graph -o graph.html |
Interactive Cytoscape graph, attack paths highlighted |
azath graph -f dot -o g.dot |
Graphviz export — dot -Tsvg g.dot -o g.svg |
azath models |
List models your LLM endpoint accepts |
azath config-check |
Show which settings are present (secrets masked) |
Tracking change over time
azath scan --json baseline.json
# ...a week later...
azath scan --json current.json
azath diff baseline.json current.json -o diff.md
Findings are matched by ID — a stable hash of the principal, role, and scope that define the path, not of anything incidental like collection order. So an unchanged tenant produces an empty diff, and a real change shows up as exactly one new or resolved finding.
Seeing the graph
azath graph -o graph.html # scans live, then renders
azath graph --from scan.json -o graph.html # or render a saved export
azath graph -f dot -o g.dot # Graphviz, no browser needed
Nodes are identities, roles, scopes, and resources; anything on a detected attack path is coloured by the worst severity of any path it appears on. The page renders the same graph the detectors traverse, so the picture and the findings can never disagree.
Layout defaults to layered left-to-right, because an attack path flows — actor, then the relationship carrying privilege, then the privilege, then its scope. A force-directed layout hides exactly that.
📊 Try it: live interactive
demo — click a finding to
isolate its path, or drag nodes around. Source:
docs/sample-graph.html
(GitHub shows the markup rather than rendering it) and a
Graphviz version
that needs no browser at all.
How it works
deterministic collectors -> typed Pydantic models -> graph + path-finder
-> provider-agnostic LLM synthesis -> Markdown report (+ JSON export)
- Collectors gather one slice each via Microsoft Graph and ARM, returning typed models. They handle paging, throttling, and partial failure. Expansion is seeded from privilege-bearing objects, so cost scales with the interesting subgraph rather than tenant size.
- Graph is
networkx. Nodes: identities, roles, scopes, resources. Edges:has_role,assigned_at,member_of,owner_of,has_managed_identity,can_add_credential_to. - Detectors traverse the graph deterministically, one per path type, each
emitting a typed
AttackPathwith severity and traceable evidence. - LLM synthesis turns those findings into the narrative. Provider-agnostic: an OpenAI-compatible client (default, targets OpenRouter) and a native Anthropic client.
Decisions worth knowing about
NotActions is the correctness crux. Contributor has Actions: ["*"] —
which matches roleAssignments/write — but excludes
Microsoft.Authorization/*/Write. Naive matching on Actions alone would flag
every Contributor in every tenant. Owner has the identical ["*"] with no
such exclusion, so it must be flagged. Both are pinned by tests.
Groups and managed identities aren't reported as actors. Nobody compromises a group. When a group holds a privileged role, the finding is attributed to the members and owners who can actually wield it. Service principals are reported directly — you can steal their existing secret — so credential-addition findings sit alongside them rather than replacing them.
Fail loud, not quiet. When a detector can't complete an attribution — a privileged group whose membership won't enumerate, a managed identity whose host isn't in scope — it says so as a finding. An unreadable privileged group is scored by scope breadth, not capped at medium, because unknown exposure on subscription Owner is not a medium problem.
Data-access model
Read-only service principal, OAuth client-credentials flow:
- Microsoft Graph
Directory.Read.All— users, groups, service principals, app registrations, directory roles. - Azure RBAC
Readerat subscription scope — role assignments, role definitions, resources.
No write permission of any kind is used or requested.
Security & authorized use
The service principal's client secret grants Directory.Read.All — effectively
keys to read the entire directory. Treat it accordingly:
- The secret lives only in a gitignored
.env;chmod 600 .env. - Rotate it periodically; grant least privilege (Reader, not more).
- Point this only at tenants you own or are explicitly authorized to assess.
- Never point it at a production or employer tenant. Use a free personal Azure account seeded with a deliberately-vulnerable lab (see the setup doc).
Development
pytest # no live Azure calls — fixtures only
mypy # --strict, clean
ruff check .
Python 3.11+, src/ layout, Typer CLI, Pydantic v2. Tests run against
captured/synthetic Azure JSON; nothing touches a live tenant, so CI needs no
credentials. CI additionally regenerates the committed sample outputs and fails
if they drift, so the examples in this repo always match the code.
Security policy and authorized-use expectations: SECURITY.md.
Limitations
- Detects the five path types above. Absence of findings is not proof of absence of attack paths.
- Point-in-time snapshot of a single subscription — no management-group traversal, PIM, or Conditional Access awareness.
- Group expansion covers groups holding role assignments and anything nested inside them; unprivileged groups aren't enumerated.
- Managed-identity hosts resolve only within the scanned subscription.
- The dangerous-action catalogue is curated, not exhaustive.
- Not a replacement for BloodHound's breadth or a CSPM's checklist coverage — complementary and deliberately focused.
Roadmap
Management-group traversal and cross-subscription paths, PIM and Conditional Access awareness, and scoring paths by exploitability as well as blast radius.
License
MIT - see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file azath-0.1.1.tar.gz.
File metadata
- Download URL: azath-0.1.1.tar.gz
- Upload date:
- Size: 89.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f0fa9b691aafce964e8a6aaad0e323fedc1f49b70e4c76b2f2e6aa77ba3dbc71
|
|
| MD5 |
ec30170c6d3741ffd574482dd7fe157c
|
|
| BLAKE2b-256 |
8b6f6aaf76f118e2cb919a1eb0a444d0f3c2eaf8c13ee510d7fe655705272e4e
|
Provenance
The following attestation bundles were made for azath-0.1.1.tar.gz:
Publisher:
publish.yml on tomerblum/azath
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
azath-0.1.1.tar.gz -
Subject digest:
f0fa9b691aafce964e8a6aaad0e323fedc1f49b70e4c76b2f2e6aa77ba3dbc71 - Sigstore transparency entry: 2643886429
- Sigstore integration time:
-
Permalink:
tomerblum/azath@a127cc6963988c766e3b416430a6e2456ff8982e -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/tomerblum
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a127cc6963988c766e3b416430a6e2456ff8982e -
Trigger Event:
release
-
Statement type:
File details
Details for the file azath-0.1.1-py3-none-any.whl.
File metadata
- Download URL: azath-0.1.1-py3-none-any.whl
- Upload date:
- Size: 74.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
df80d00c5d28af086a01be318c76d9ba736c49628dd2c158217d39b2353f7ce7
|
|
| MD5 |
6e30eb77e03fdb587d3e33d52b69bc9f
|
|
| BLAKE2b-256 |
4b5bd6a3ba5349201aefbfbba28b6ccc95502b3546529ab1c202bc9c038f4601
|
Provenance
The following attestation bundles were made for azath-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on tomerblum/azath
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
azath-0.1.1-py3-none-any.whl -
Subject digest:
df80d00c5d28af086a01be318c76d9ba736c49628dd2c158217d39b2353f7ce7 - Sigstore transparency entry: 2643886582
- Sigstore integration time:
-
Permalink:
tomerblum/azath@a127cc6963988c766e3b416430a6e2456ff8982e -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/tomerblum
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@a127cc6963988c766e3b416430a6e2456ff8982e -
Trigger Event:
release
-
Statement type: