Cyber threat graph, attack-path analysis
Objective
Modern attacks chain low-severity vulnerabilities across many hops to reach high-value targets. Defenders need to ask "from any internet-facing asset, can an attacker reach the customer database?" Modeling assets, network reachability, and CVEs as a graph reduces this to a path search — the basis of tools like BloodHound and Microsoft Defender ASM.
Step 1: Create the graph
// Public-facing assets
MERGE (web:Asset {name: "web-prod-01", tier: "public", crown_jewel: false})
MERGE (api:Asset {name: "api-gw-02", tier: "public", crown_jewel: false})
// Internal services
MERGE (auth:Asset {name: "auth-svc-03", tier: "internal", crown_jewel: false})
MERGE (jobs:Asset {name: "jobs-svc-04", tier: "internal", crown_jewel: false})
MERGE (cache:Asset {name: "cache-05", tier: "internal", crown_jewel: false})
// Data tier — the crown jewels
MERGE (custdb:Asset {name: "customers-db", tier: "data", crown_jewel: true})
MERGE (paydb:Asset {name: "payments-db", tier: "data", crown_jewel: true})
MERGE (vault:Asset {name: "secrets-vault", tier: "data", crown_jewel: true})
// Workstations (lateral-movement waypoints)
MERGE (admin:Asset {name: "admin-laptop", tier: "endpoint", crown_jewel: false})
// Network reachability (CONNECTS_TO is directed: caller -> callee)
MERGE (web)-[:CONNECTS_TO]->(api)
MERGE (api)-[:CONNECTS_TO]->(auth)
MERGE (api)-[:CONNECTS_TO]->(jobs)
MERGE (auth)-[:CONNECTS_TO]->(cache)
MERGE (auth)-[:CONNECTS_TO]->(custdb)
MERGE (jobs)-[:CONNECTS_TO]->(paydb)
MERGE (cache)-[:CONNECTS_TO]->(vault)
MERGE (admin)-[:CONNECTS_TO]->(jobs)
MERGE (admin)-[:CONNECTS_TO]->(auth)
// Vulnerabilities present on assets
MERGE (cve1:CVE {id: "CVE-2024-1101", cvss: 9.8, kind: "RCE"})
MERGE (cve2:CVE {id: "CVE-2024-1102", cvss: 7.5, kind: "AuthBypass"})
MERGE (cve3:CVE {id: "CVE-2024-1103", cvss: 6.4, kind: "SSRF"})
MERGE (cve4:CVE {id: "CVE-2024-1104", cvss: 8.1, kind: "PrivEsc"})
MERGE (web)-[:HAS_VULN]->(cve1)
MERGE (api)-[:HAS_VULN]->(cve3)
MERGE (auth)-[:HAS_VULN]->(cve2)
MERGE (jobs)-[:HAS_VULN]->(cve4)
MERGE (cache)-[:HAS_VULN]->(cve3);
Step 2: Find exploitable attack paths to crown jewels
// Path from any public asset to any crown jewel where every non-target hop
// hosts a vuln with CVSS >= 7.
MATCH path = (entry:Asset {tier: "public"})-[:CONNECTS_TO*1..6]->(target:Asset {crown_jewel: true})
WITH path, entry, target, nodes(path) AS hops
UNWIND hops AS hop
OPTIONAL MATCH (hop)-[:HAS_VULN]->(v:CVE)
WITH path, entry, target, hops, hop,
max(CASE WHEN v.cvss >= 7.0 THEN v.cvss ELSE 0 END) AS hop_score,
collect(v.id) AS hop_cves
WITH path, entry, target,
collect({name: hop.name, score: hop_score, cves: hop_cves, is_target: hop = target}) AS hop_info
WHERE ALL(h IN hop_info WHERE h.is_target OR h.score >= 7.0)
RETURN entry.name AS entry_point,
target.name AS crown_jewel,
[h IN hop_info | h.name] AS hop_names,
[h IN hop_info WHERE NOT h.is_target | h.cves] AS exploited_cves,
size(hop_info) - 1 AS hops
ORDER BY hops, entry_point;
What's happening
- The pattern starts at any
tier: "public"asset and walksCONNECTS_TOup to 6 hops to anycrown_jewel: trueasset — defining "blast surface" structurally. - The
ALL(... EXISTS {...})predicate prunes paths to those where every traversed node hosts an exploitable vulnerability (CVSS >= 7) — a defensible attack chain, not a theoretical one. - The list comprehension extracts the specific CVE used on each hop, so the output reads like an attacker's playbook: "exploit CVE-X on web, pivot via CVE-Y on api, land in customers-db."
- This is a graph-native rephrasing of MITRE ATT&CK kill-chain analysis. Patching any one node on a path breaks the chain — graph queries make "minimum cut" thinking practical for SecOps.
- Compared to SQL: walking 6-hop network connectivity with per-hop predicates would require six recursive joins and complex predicate plumbing — the Cypher version fits on a screen.
Try this next
MATCH (a:Asset)-[:HAS_VULN]->(v:CVE)
WHERE v.cvss >= 8.0
RETURN a.name AS asset, collect(v.id) AS critical_cves
ORDER BY size(critical_cves) DESC;
MATCH path = shortestPath((p:Asset {tier: "public"})-[:CONNECTS_TO*]-(j:Asset {crown_jewel: true}))
RETURN p.name AS from_public, j.name AS to_jewel, length(path) AS hops
ORDER BY hops;
MATCH (a:Asset)-[:CONNECTS_TO]->(b:Asset)
RETURN a.tier AS from_tier, b.tier AS to_tier, count(*) AS edges
ORDER BY edges DESC;