Your Cloud Is a Graph. Your Security Tools Don't Know That.
- #cloud
- #security
- #open-source
- #graph-database
- #aws
- #iam
- #attack-paths

Inventory is a list. Compromise is a path.
Ask a cloud engineer to describe their AWS account and you get an inventory grouped by service and region. Ask someone who breaks into cloud environments for a living and you get a route.
A load balancer fronts an instance. The instance carries a role. The role can assume a second role in another account. That role can read a bucket nobody has opened since 2021. Every fact is boring alone. Strung together they are the incident.
Why rule engines miss it
Posture management tools evaluate one resource at a time, which means they assign severity one resource at a time. Four findings from an ordinary week:
| Finding | Severity |
|---|---|
| Security group allows 0.0.0.0/0 on 443 | Low, it is a web server |
| Instance profile has a broad role attached | Medium |
| Role trusts a role in another account | Low, that is how we deploy |
| Bucket is private, readable by that role | Informational |
Nothing above medium. Ticket closed. Now connect them:
The chain is the finding. No single check was wrong and no single check could have caught it, because the vulnerability does not live in a resource. It lives between them. A list cannot express a path, so this is a data model problem, not a tuning problem.
Cartography
Cartography pulls your infrastructure into a Neo4j graph database: resources become nodes, relationships become first-class edges, and you ask questions in Cypher.
Built at Lyft, open sourced in 2019, accepted into the CNCF at Sandbox maturity in August 2024. It ships 50+ integrations, which matters more than it sounds:
Real attack paths do not respect product boundaries. They start at an identity provider, pass through a code host, and end at a database. One graph holding all three can express that in a single query. Three consoles cannot.
The sync is a batch job using credentials you already have. Writes use MERGE, so re-running converges instead of duplicating, and a cleanup pass deletes anything not touched by the current run. Without that second part the graph slowly fills with resources that no longer exist, which is worse than having no graph at all.
Three queries worth running first
Which internet-facing instances carry a role?
MATCH (:IpRange {id: '0.0.0.0/0'})-[:MEMBER_OF_IP_RULE]->(:IpPermissionInbound)
-[:MEMBER_OF_EC2_SECURITY_GROUP]->(sg:EC2SecurityGroup)
<-[:MEMBER_OF_EC2_SECURITY_GROUP]-(i:EC2Instance)
MATCH (i)-[:STS_ASSUMEROLE_ALLOW]->(r:AWSRole)
RETURN i.instanceid, i.publicdnsname, r.arnWhat is the blast radius of one compromised role?
MATCH path = (:AWSRole {arn: $compromised})
-[:STS_ASSUMEROLE_ALLOW*1..4]->(reached:AWSRole)
RETURN reached.arn, length(path) AS hops ORDER BY hopsThat *1..4 is the entire argument for a graph database. It means follow this edge up to four times and show me where it lands. Against a relational inventory it is four self-joins and a query nobody will maintain.
Which people can reach production, and through which group?
MATCH (u:OktaUser)-[:MEMBER_OF_OKTA_GROUP]->(g:OktaGroup)
-[:ALLOWED_BY]->(r:AWSRole)-[:POLICY]->(p:AWSPolicy)
WHERE p.name CONTAINS 'Admin'
RETURN u.email, g.name, r.arnThis one starts at a person and ends at an AWS policy. Without a graph it is two exports and a spreadsheet, which is why nobody runs it more than once a year.
Tracing a real breach
Capital One, 2019. Every hop is individually defensible.
A rule engine sees a bad WAF rule, an instance with a role, and a private bucket. Three findings, none critical. A graph sees four hops from the public internet to customer data, and can be asked for every other path shaped like it:
MATCH p = (:IpRange {id: '0.0.0.0/0'})-[*1..6]->(b:S3Bucket)
WHERE b.anonymous_access = false
RETURN p LIMIT 25Cartography would not have prevented the breach. It makes "what else looks like this" a query instead of a quarter-long audit.
Getting started
git clone https://github.com/cartography-cncf/cartography.git
cd cartography
docker compose upOpen Neo4j on localhost:7474 and start with the smallest useful question:
MATCH (a:AWSAccount)-[:RESOURCE]->(i:EC2Instance)
RETURN a.name, count(i) AS instancesGive it read-only credentials. It is a mapping tool and has no reason to write anything. The documentation covers per-provider setup and the permissions each integration needs.
Its drift-detection mode diffs the graph between runs. People discover it last and rely on it most, because a snapshot answers "is this safe now" while a diff answers "what changed since it was", which is the question you actually have at 2am.
Where it does not help
It is a snapshot, not a stream. Between syncs the graph is stale. A role created and used within the hour may never appear. It complements CloudTrail rather than replacing it.
Reachability is not exploitability. A path means permissions allow it. It says nothing about SCPs, permission boundaries, trust policy conditions, or network controls a step removed. Expect to triage findings that turn out to be blocked elsewhere.
Someone has to own the queries. You get a graph, not a verdict. With nobody asking it questions you have installed a thorough inventory system and gained nothing.
Neo4j is now yours to run. Size it, back it up, keep it off the internet. Breaching yourself with your own attack-path mapper would be a poor way to prove the thesis.
The shift
Security is moving from "is this resource configured correctly" to "what can be reached from here". Every major vendor has shipped some form of attack path analysis recently, and underneath they are all building a graph. Cartography is that idea without the license.
Start with the smallest question you cannot currently answer. Mine was which internet-facing services could reach the customer database and by what route. It took ten minutes once the graph existed, and had been unanswerable for a year before that.