researchllmagents

CypherMeThat

No more NEO4J cypher queries! We need the real stuff! Only Bloodhound cypher queries!

About a month into working at SpecterOps, I had one of those humbling moments. I was watching a colleague blast through a string of BloodHound Cypher queries and adapting them on the fly, chaining conditions, pulling exactly what they needed from the graph. Meanwhile, I’m sitting there feeling like an imposter. I knew my Cypher skills weren’t where they needed to be, and that was just the reality of it.

The longer-term fix was obvious: study. I put together a full study guide and regiment to actually get up to speed working through the query library, understanding the graph schema, practicing against real & fake data. That’s the path forward.. But that path takes time, and in the meantime I still had to show up at work everyday.

So I asked myself the question I usually ask when I’m stuck: why not just build something? It’s one my favorite hobbies, so off I went to figure out how to build a decent BloodHound Cypher query tool.

The Real Problem

The instinct when you want AI to help with Cypher queries is to reach for ChatGPT or Claude and just ask. And it’ll give you something that looks right. The problem is that BloodHound doesn’t run Neo4j the way most people think it does. The current version uses PostgreSQL as the backend, but it uses a Cypher-like query language called CySQL that hits a graph projection layer, not a native Neo4j instance. So when an AI pulls from its training data and hands you a query built on Neo4j procedures, APOC calls, or generic graph syntax, it just doesn’t work. You paste it into BloodHound Cypher Search and get “No results match your criteria” or an error.

On top of that, the query has to match the actual BloodHound schema including the exact label, exact tag, correct edge direction, traversable vs. non-traversable edges, the right properties. User is not user. MemberOf is not memberof. The graph is opinionated and the AI doesn’t know that by default.

Building CypherMeThat

The fix was to stop asking AI to guess and give it something it could actually work from. Using claude.ai web extension, I went through the SpecterOps documentation and grabbed screenshots of every node and edge for every technology BloodHound currently supports: Active Directory, Azure/Entra, Jamf, GitHub, and Okta. From there, I built out a structured reference: the full schema index (92 node kinds, 323 edge types), the official OpenGraph extension query examples, and a curated set of 237 verified queries pulled from the BloodHound Cypher library and built-in query collections. Of course, I automated where automation could accurately occur. :)

All of that gets packaged as an AI skill, which becomes a structured set of references and generation rules that an LLM must read before it touches a query. The workflow looks like this:

  1. Read the query patterns and known pitfalls first
  2. Check lessons learned from real usage
  3. Search the verified query corpus for something close
  4. Confirm labels, edge types, and traversability in the schema index
  5. Draft the query in the closest verified style
  6. Lint it against the schema before responding

The linter (lint_cypher.py) catches unknown labels, unknown edge types, risky clauses, and things like WITH which is flat-out not supported in BloodHound Cypher Search even though it’s valid Cypher syntax. Again, there would be no way for the LLM to know any of this outside of it’s training data. BloodHound Enterprise cypher is nuanced and unique. So, why work harder when we can work smarter.

Visible Differences: Success and Failure

After completing CypherMeThat, of course, I went on a testing spree to see the difference between using the skill.md and not. Well, the results don’t lie. Here is an example:

Using CypherMeThat

alt text alt text

MATCH p=shortestPath((s:User)-[:AD_ATTACK_PATHS*1..]->(t:Group))
WHERE s.name = 'YODA@STARWARS.LOCAL'
AND t.name = 'DOMAIN ADMINS@STARWARS.LOCAL'
AND t.objectid ENDS WITH '-512'
AND s<>t
RETURN p
LIMIT 1000

Using claude with no skill:

alt text

MATCH p=shortestPath(
  (u:User {name:'YODA@STARWARS.LOCAL'})-[*1..]->(g:Group {name:'DOMAIN ADMINS@STARWARS.LOCAL'})
)
RETURN p

alt text alt text

Why It Matters

The difference between a query that works and one that doesn’t evitably must be battle-tested. Both look like valid Cypher. The AI has no way of knowing which is which unless it has the right ground truth to work from. That’s the whole point of CypherMeThat.. rather than the model hallucinating Neo4j syntax and hoping for the best, it builds every query from a verified, schema-validated reference that actually reflects how BloodHound works.

Now, I can just be an imposter with a cool new tool!

The repo is at github.com/0xSA-X1/CypherMeThat if you want to pull it for Codex or Claude. Catch you in the next one.