Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 10 min read

30 of 33 attack paths ran through one link: Choke Point on FalkorDB

A marketing contractor called alice.moreno clicks the wrong link. Someone on the security team now has to answer one question before lunch: what do we change? Not "what could the attacker reach". One change, the one that

30 of 33 attack paths ran through one link: Choke Point on FalkorDB

A marketing contractor called alice.moreno clicks the wrong link. Someone on the security team now has to answer one question before lunch: what do we change? Not "what could the attacker reach". One change, the one that helps most.

A permissions audit gives you a list. Alice is in a group, that group is nested in another, that one has admin on a jump host, a domain admin is logged in there. In my demo company there are 33 such routes from Alice to the domain controller and three key secrets. Nobody fixes 33 things by lunch.

But 30 of those routes share one link: Legacy-VPN-Users nested inside IT-Support, left over from a 2021 VPN migration. Cut it and 3 routes remain. One more change and it is 0.

I built Choke Point to find that link and say so in plain English, for the WeMakeDevs x FalkorDB Graph Hacks hackathon. The company, Northwind Freight, is synthetic: 671 nodes, 1,160 edges, from a seeded generator. I have not run it on real company data.

What it does

Choke Point tracing alice.moreno: 33 attack paths, the graph, a checked answer and a draft fix ticket

You pick a compromised user, group or host. It finds every route to the crown jewels, ranks the links by how many routes use them, previews the top cut, and drafts a fix ticket. A person approves it. The approved fix is stored in the graph, so a later scan can check it still holds.

A short recording: trace, preview the cut, approve the fix, the counter drops from 33 to 3

Live demo: https://choke-point.vercel.app (every visitor gets a private copy of the graph, so click anything).

Why a graph

Attack routes are chains of unknown length. Vector search is the wrong tool for them. Embeddings answer "what is similar to this". My question is "what is connected to this, and through how many routes". There is nothing to embed.

SQL can do it. A recursive CTE finds the routes. But I want more than reachability. I want a count of routes per link, so I would keep every path, unnest it, group by edge and guard against loops. In Cypher the path is the thing you match:

MATCH p=(s:Entity {name: $from})-[:MEMBER_OF|ADMIN_ON|HAS_SESSION|CAN_READ*1..10]->(t:Entity {crown: true})
WHERE ALL(r IN relationships(p) WHERE r.removed = false)
  AND ($without = '' OR NONE(r IN relationships(p) WHERE r.id = $without))
RETURN count(p) AS n

That is countPaths in src/lib/graph/queries.ts, with the relationship list written out. A variable-length match never reuses an edge inside one path, so loops cannot run forever.

Ranking the links is the same match plus a few lines:

// same MATCH and WHERE as above, then:
UNWIND relationships(p) AS r
WITH r WHERE startNode(r).name <> $from
WITH r.id AS id, count(*) AS paths
ORDER BY paths DESC, id
LIMIT 5

UNWIND turns each path into its links, count(*) per edge id says how many paths use that link, and the top row is the choke point. I skip links that start at the compromised identity, because "remove Alice from her own group" is not a lasting fix.

Two links tie on 30: Marketing-Contractors into Legacy-VPN-Users, and Legacy-VPN-Users into IT-Support. Cutting either leaves 3. The tie breaks by edge id, which is alphabetical, so a person should choose. The ticket shows the note stored on the edge ("Nested by mistake, nobody owns it").

Edges point the way an attacker moves

(User)  -[:MEMBER_OF]->   (Group)
(Group) -[:ADMIN_ON]->    (Host)
(Host)  -[:HAS_SESSION]-> (User)     credentials are cached there
(Group|Host) -[:CAN_READ]-> (Secret)

HAS_SESSION points from the host to the user. That looks backwards until you think like the attacker: with admin on a host, you can take the credentials of whoever is logged in there. Once every edge means "from here you can get there", a path is an attack and the question reads left to right.

What the model is not allowed to do

The model never writes Cypher. It gets a fixed catalogue of twelve tools with typed arguments. This is one of them, from src/lib/agent/tools.ts:

choke_points: tool({
  inputSchema: z.object({ identity }),
  execute: wrap("choke_points", async ({ identity }) => {
    const c = await check(identity, tenant);
    if (!c.ok) return c.out;
    const r = await chokePoints(c.name, { tenant, top: 5 });
    return { status: "ok" as const, identity: c.name, ...r };
  }),
}),

Three rules sit around that:

  • Every tool checks the name against the graph first. A name that is not there returns unknown_entity with close matches, and the final answer is fixed text: not in the graph, no query was run.
  • Zero paths is never "safe". The checker rejects "safe" or "cannot reach" when every result has zero paths. The honest line is that none is modelled.
  • Fix tickets come from templates in src/lib/graph/fixes.ts, so they cannot invent a group name or a command. The agent can draft a fix. Only the Approve button applies one.

How the answer is checked

The model picks tools and writes a few sentences. Before they are shown, validateAnswer in src/lib/agent/answer.ts compares them with the rows the tools returned. Every name in backticks must appear in the results, and so must every number.

const quoted = [...text.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
for (const q of quoted) {
  if (!seen.strings.has(q)) problems.push(`"${q}" did not come from a query`);
}
// ...
for (const m of bare.matchAll(/(?<![\w.])(\d+(?:\.\d+)?)%?(?!\w|\.\w)/g)) {
  const n = Number(m[1]);
  if (!seen.numbers.has(n)) problems.push(`the number ${m[1]} did not come from a query`);
}

I ran it against the real alice.moreno rows. A correct sentence passes. These do not:

  • "alice.moreno has thirty-four attack paths." fails with the number "thirty-four" did not come from a query.
  • "Legacy-VPN-Users sits on 0.909 of the paths." fails with the number 0.909 did not come from a query.
  • "Domain-Admins is the problem." fails with "Domain-Admins" did not come from a query.

The second one is not made up. Gemini once answered with "0.909..." for a share of paths. The tool output now returns an integer percent (91), and the checker also reads spelled-out numbers, so "thirty" cannot slip past a check that only looks for digits.

When a check fails, the reply is replaced by a sentence built from the rows (summarize), and the interface says so. The cost is a duller answer. The gain is that every number on screen came from the database.

There is a gap. The check does not catch a wrong claim built from correct names and numbers.

The preview is exact

Previewing a cut means counting again without one edge. Removing an edge removes exactly the paths that go through it, so the drop is the number of paths that edge was on. No graph is copied and nothing is written. The fix plan uses the same idea: a list of edge ids to leave out of the match. Each step picks the link on the most paths still left, then counts again.

Fix plan for alice.moreno, 33 to 3 to 0, two steps with tickets

The plan is greedy, so it is not proven to be the smallest set of cuts. The number for each step is exact. For the whole company it counts users as well as paths.

Fix plan for the whole company: 19 users to 14 to 0, 366 paths to 69 to 0

Of 313 users, 19 can reach the crown jewels over 366 paths. The first step, Tier0-Admins out of Domain Admins, cuts 297 and leaves 14 users and 69 paths. The second, admin rights for Backup-Operators on BACKUP-01, leaves 0.

The browser repeats the counting on the subgraph it receives, so the preview is instant. In an acyclic graph, the paths through an edge are the ways to reach its start times the ways from its end to a crown jewel. A test checks the browser totals against the database.

Memory is more nodes

When a person approves a fix, approveFix first checks that the link is on a live path. Then it sets removed = true on the edge and writes a :Fix node with ABOUT edges to both ends of the cut link and FOUND_FROM to the identity.

Now press "Grant a new permission" in the app. It adds a membership with a note ("Added for ticket OPS-4471 so contractors can restore their own files") and a timestamp. A re-scan asks only for routes that use a link newer than the last fix:

// same MATCH and live-edge WHERE as above, plus:
AND ANY(r IN relationships(p) WHERE r.addedAt > $since)

Asked "does the last fix still hold?", the agent reports how many paths the fix cut, then says new paths have opened through a link added after it, and names one route. The memory is the same graph, queried the same way.

One graph per visitor

Anyone can approve fixes on the public demo, so each visitor gets a cookie, and on the first API call the app copies a pristine org:template graph into org:s-<sid>:

async function copyFromTemplate(name: string) {
  try {
    await (await getGraph(TEMPLATE)).copy(name);
  } catch (err) {
    if (!(await exists(name))) throw err;
  }
  await expire(name, sandboxTtl());
}

GRAPH.COPY runs inside the database. A graph is a key, so EXPIRE sets its lifetime, and every visit pushes it forward: a sandbox lives three hours after last use. You can paste a CSV edge list into your sandbox too. A name's kind is worked out from how it is used: the target of ADMIN_ON is a host.

The import panel with a second synthetic company loaded

What FalkorDB gave me

  • Variable-length path matching carries almost everything: trace, choke points, preview, plan and company view are all counts over one match.
  • algo.betweenness answers "which node carries the most load" across the company. It scores shortest routes, a different question from my all-routes count, so it is its own tool. It takes only labels and types, so it still counts removed edges.
  • Parameterised queries, so a name from someone's CSV never becomes query text. Only constants and range-checked integers go into query text.
  • One graph per tenant, GRAPH.COPY with EXPIRE, and a 5 second TIMEOUT on every read query.

What went wrong

The Cloud user cannot run SET or EVAL. The template build needs a lock, so that the first of many serverless instances builds it and the others wait. A Redis lock was out. The fix is a counter in a guard graph: MERGE (m:Meta {k: $k}) SET m.n = coalesce(m.n, 0) + 1. The first caller in each two-minute window builds into a scratch graph, then copies it over in one step, so nobody copies a half-built template. It is a window, not a real lock.

The Cloud database was restarted and emptied. The demo graph was gone. The app now notices a missing default graph or template and builds it again.

Import made loops real. The demo generator keeps the graph acyclic, and my fast browser count assumed no cycles. Then CSV import let someone put two groups inside each other. Now an acyclic graph gets the fast count, and a graph with a loop is walked edge by edge, like Cypher counts, capped at 200,000 steps. A test compares both with the database, including the loop.

Limits

  • The data is synthetic. There is no importer for real exports such as BloodHound.
  • A path here is a route through permissions. It is not proof that an attacker could use it.
  • Paths are counted up to 10 hops by enumeration. A dense imported graph can pass the 5 second limit, and the page then says "took too long".
  • The plan is greedy, and the biggest link may be one you cannot cut. On the demo, step one is a Domain Admins membership. The plan gives numbers. A person gives judgement.

What is next

An importer for BloodHound-style exports, edge weights (all 1 today), and a comparison of the greedy plan with an exact minimum cut, to see how far apart they are.

Try it

Trace alice.moreno at https://choke-point.vercel.app, press "Preview the cut", approve the fix, then press "Grant a new permission" and "Re-scan". The code, the tests (123 pass) and the README:

GitHub logo Sarcastic-Soul / choke-point

Attack-path agent on a FalkorDB graph: finds the one link that cuts the most paths to the crown jewels. WeMakeDevs x FalkorDB Graph Hacks.

Choke Point

CI Live demo: choke-point.vercel.app

An agent that answers one question for a security team: if this account is compromised, which single change cuts the most attack paths?

Choke Point after tracing alice.moreno: 33 attack paths, the graph, the checked agent answer and a draft fix ticket

It reads a company graph in FalkorDB and finds every route from the compromised identity to the crown jewels (the domain controller and three key secrets). It ranks the links by how many routes pass through them, shows what happens if the top link is cut, and drafts a fix ticket. A person approves the fix. The approved fix is stored in the graph, so the next scan can check that it still holds.

On the demo data, alice.moreno (a marketing contractor) has 33 attack paths. One group nested inside another by mistake, Legacy-VPN-Users inside IT-Support, sits on 30 of them. Cut that one link and 3 paths are left. A second fix takes them to 0.

A short screen recording: trace, preview the cut, approve the fix, the counter drops from 33 to 3

Built for…

Choke Point on a phone: the 33 paths counter and the graph, which scrolls sideways

Built with: Next.js 16, FalkorDB (Docker locally, FalkorDB Cloud online), the Vercel AI SDK with Gemini, Cytoscape, bun, deployed on Vercel.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.