FalkorDB 上的 Choke Point:33 條攻擊路徑中,30 條經過同一連結
30 of 33 attack paths ran through one link: Choke Point on FalkorDB
Choke Point 在 FalkorDB 合成圖中找出 33 條攻擊路徑,其中兩條連結各出現在 30 條路徑中,並列為瓶頸。示範圖包含 671 個節點及 1,160 條邊;移除其中任一連結後,剩下 3 條路徑。模型不能撰寫 Cypher,修正須經人員批准才會套用。
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
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.
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_entitywith 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.morenohas thirty-four attack paths." fails withthe number "thirty-four" did not come from a query. - "
Legacy-VPN-Userssits on 0.909 of the paths." fails withthe number 0.909 did not come from a query. - "
Domain-Adminsis 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.
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.
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.
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.betweennessanswers "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.COPYwithEXPIRE, and a 5 secondTIMEOUTon 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 Adminsmembership. 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:
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
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?
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.
Built for…
Built with: Next.js 16, FalkorDB (Docker locally, FalkorDB Cloud online), the Vercel AI SDK with Gemini, Cytoscape, bun, deployed on Vercel.
來源:DEV Community · dev.to







