Skip to content

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.

attack path analysiscrown jewel reachabilityprivilege escalation path detection

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.

One identity, three records, one chaingraph edges · correlation window 24h
Where it sitsWhat the record holds
EntryEASM_OPEN_PORT on a service listening to the internet — LOW severity alone
Pivotan ASSUMES_ROLE edge the workload can follow, with trust conditions annotated
TargetHAS_ACCESS_TO a datastore whose asset sensitivity is CRITICAL
Joinedopen port plus crown-jewel escalation path on the same asset, ranked RULE-T2-NEW-HI-003

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.

TermValue
Severity baseCRITICAL 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
Cap10.0
Bands≥9 CRITICAL · ≥7 HIGH · ≥5 MEDIUM · ≥3 LOW
src/correlation/scoring.ts — the same inputs produce the same score on every run.

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 a CRITICAL or HIGH unpatched vulnerability on the same entity, with publiclyExposed set when the path starts at INTERNET.
  • 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
Each path as a list of typed edges, the start and end node, the hop count, the feasibility score, the trust-policy conditions annotated on each hop, and the ATT&CK techniques derived from the edge types themselves rather than attached by hand.
ATP-02A specific place to break the chain
Because the finding is the path and not the node, the fix is an edge: narrow the trust principal, add the 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
Stored routes keyed by a hash of their node list, with feasibility recomputed on each scan. That is the artefact that separates "this route appeared yesterday" from "this route has existed since the account was created and nobody has looked at it".

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?
A critical finding is a property of one resource. A path is a property of the route between resources, which is why the score depends on how many steps are in it, whether the entry is reachable from the internet and whether the destination is a crown jewel. Two accounts can have identical critical findings and wildly different paths, and only one of them has a datastore at the end of a route an attacker can walk.
Do we need flow logs for this to be useful?
No. The core of the graph is built from control-plane and posture data you have already granted read access to: IAM trust policies, role and permission metadata, asset sensitivity, exposure findings. Flow logs add 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?
It is a function of length, nothing more: 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?
Yes. Customer rules can be added to the pack, and both tiers are declarative: tier 1 evaluates one signal against its context, tier 2 joins signals inside a time window. A path signal type — 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.