Attack path analysis
A findings list sorts by severity. A graph sorts by consequence, because what makes a chain critical is not any single step — it is that every step in it is reachable from the same identity.
The problem
Take three findings in one cloud account. A service listening on an internet-facing port. A role whose trust policy lets a workload assume another role. A datastore reachable from that role. In a scanner's output these are three rows sorted by severity, and none of them is at the top of the list, because none of them is severe on its own.
Joined, they are one path with an entry, a pivot and a destination. The exposure is the way in, the trust policy is the way across, the datastore is the reason anyone bothered. None of the three is a vulnerability in the sense a scanner means: each is a configuration somebody typed deliberately, and each is defensible in isolation. The defect is the composition, and composition is not a field on any of the three records.
Chains also have a clock and a direction. An edge that existed three weeks ago and a credential that leaked this morning are not a chain; they are two facts with a story attached. Correlation runs over a 24-hour window by default, with 7 days for sequences that need it, and a signal drops out of the window the moment its TTL expires. Direction matters for the same reason: the same two edges read the other way round are not an attack.
How Hanu approaches it
The graph is Neo4j, partitioned by tenant, and it is deliberately narrow. Nodes are Asset, Identity, Workload, Service and Pipeline. Relationship types are an allowlist: ASSUMES_ROLE, CAN_ACCESS, EXPLOITS, CONTAINS, DEPENDS_ON, HAS_ACCESS_TO, STORES_DATA_IN, FLOWS_TO, RUNS_ON. A collector that tries to write a label or an edge type outside that set fails loudly, which is how you avoid a graph full of edges nothing can query.
Two engines walk it. PrivEscChainEngine asks the attacker's first question — following ASSUMES_ROLE from any identity, do you arrive at one whose privilegeLevel is ADMIN? — and emits each answer as ATTACK_PATH_DISCOVERED at CRITICAL with the hops in order. KShortestPathsEngine walks up to 8 hops from every identity to every asset whose sensitivity is CRITICAL, and scores feasibility as 1.0 − 0.12 × hops, floored at 0.05. A two-hop path scores 0.76; an eight-hop path scores 0.05 and is still stored, because a long route that exists is worth knowing about even when it is expensive to use.
Edges are not drawn from a raw policy blob. Trust policies are partially evaluated, and the engine records what it did with each condition. aws:SourceAccount removes an edge when the principal account is known and outside the allow-list. sts:ExternalId, aws:MultiFactorAuthPresent, aws:SourceIp and aws:SourceVpc annotate the edge instead of removing it, because the caller's identity and network position are unknown when the graph is built. OIDC :sub patterns are annotated when they are bounded, and exact IRSA subjects wire service-account nodes with no wildcard. CIDR arithmetic, IpAddress operators, aws:SourceArn and time conditions are intentionally over-approximated.
The over-approximation is the honest choice. An edge with a condition attached is a question for a person; a missing edge is a false negative you never see. The cost is that a path which a SourceIp condition would have blocked can still be drawn, so the condition is carried on the edge where it can be read.
| Term | Value |
|---|---|
| Severity base | CRITICAL 9.0 · HIGH 7.0 · MEDIUM 5.0 · LOW 3.0 |
| Each additional contributing signal | +0.5, capped at +2.0 |
| Publicly exposed | ×1.3 |
| Crown-jewel reach | ×1.6 |
| PII, PHI or PCI in the blast radius | ×1.4 |
| In CISA KEV | ×1.5 |
| EPSS above 0.5 | ×1.0 up to ×1.4 |
| Joined on the same entity | ×1.15 |
| Cap | 10.0 |
| Bands | ≥9 CRITICAL · ≥7 HIGH · ≥5 MEDIUM · ≥3 LOW |
The multipliers are applied to a base, not plucked from a model. Three LOW signals that share an entity and sit behind a publicly exposed, crown-jewel asset come out at 3.0 plus 1.0 for the extra signals, times 1.3, times 1.6, times 1.15 — 9.57, which lands in the CRITICAL band, the same band as a single CRITICAL finding with nothing behind it. Add PII to the destination and the chain exceeds the cap; the bands are what does the ordering near the top, and the multipliers exist to move a chain across a band boundary.
The same-entity boost is conditional on purpose. Two signals join when they share an entityId or one of the metadata keys imageDigest, hostId or assetId. When they do not share one, the chain is still emitted — correlation keeps its recall — but without the 1.15, and the narrative says tenant-level co-occurrence instead of claiming the same resource. A score that quietly inflated itself would be worse than no score.
RULE-T2-004— an attack path plus aCRITICALorHIGHunpatched vulnerability on the same entity, withpubliclyExposedset when the path starts atINTERNET.RULE-T2-NEW-HI-003— an internet-facing port and a crown-jewel escalation path on the same asset: the entry-then-escalate sequence a purple team runs as a demonstration.RULE-T2-001— a leaked secret plus a reachable privileged role in one window, which is a complete takeover path rather than a leaked key.RULE-T2-010— a SIEM anomaly plus an attack path, so a runtime oddity is attached to a route an attacker could actually take.
Stored paths are keyed by a sha256 hash of their node list, so a re-scan updates a route's feasibility instead of creating a second copy of it, and you can ask whether a path is new this week or three weeks old. Paths are exposed through /api/attack-paths, and the per-identity view returns graph paths alongside the stored ones.
What you get
ATP-01Ranked chains with their edges
ATP-02A specific place to break the chain
ExternalId condition, remove the datastore grant that nothing needed. Break one hop, re-run, and the path is gone — or it is not, and you have learned something about a second route.ATP-03Path history
Limits
The graph contains what the collectors can see, and a resource type with no collector is not mis-scored — it is invisible. There is no node, no edge and no path for it. Oracle Cloud is the concrete case: OCI exists in the codebase as stubs only, so nothing in an OCI tenancy will ever appear in a path. Coverage of a path is therefore bounded by coverage of its two ends, and the honest answer to "is this everything?" is no.
The trust-policy evaluation is over-inclusive by design, as described above: CIDR arithmetic, IpAddress operators and time conditions are not evaluated, so a route that a condition would have blocked can still be drawn. The condition is carried on the edge for a human to read; the graph will not do that arithmetic for you. The graph also models identities, workloads and assets rather than every network hop — FLOWS_TO edges exist only where you have connected flow data — and nothing here models a person being persuaded to reset a password.
Questions
How is an attack path different from a critical finding?
Do we need flow logs for this to be useful?
FLOWS_TO edges, which answer a different question — whether traffic actually moves along the route — and they are an enrichment, not a prerequisite.What does the feasibility score actually mean?
1.0 − 0.12 × hops, floored at 0.05. A two-hop path scores 0.76 and an eight-hop path scores 0.05, so the score says how cheap the route is to use, not how likely an attacker is to find it. It is deliberately not a probability, and no model is involved in producing it.Can we add our own path rules?
ATTACK_PATH_DISCOVERED or PRIVILEGE_ESCALATION_PATH — is available to any rule you write, and the marketing site is not the only place a rule id is documented: every rule ships with its ATT&CK mapping, evidence fields and a test.Run it in your own account
One Docker Compose stack, your datastores, read-only credentials into your clouds. The Community edition is free to run in production.