DEV Community

Cover image for One phished login, 18 poisoned packages, 74 minutes of silence: so I built a graph that asks who can poison your app
Lay Patel
Lay Patel

Posted on

One phished login, 18 poisoned packages, 74 minutes of silence: so I built a graph that asks who can poison your app

On September 8, 2025, someone phished the npm login of one well-known maintainer, @qix.

Within minutes, poisoned versions of 18 packages went out: debug, chalk, ansi-styles, strip-ansi… packages with around 2 billion downloads a week between them, sitting deep inside millions of apps.

The first public advisory showed up at OSV 74 minutes later. Until then, every scanner that works from advisories said the same thing: all clear.

That stuck with me. Those scanners weren't broken. They were answering the wrong question.

The question nobody was asking

Security tools ask "is this version known to be bad?" That only works after someone has noticed, investigated and published an advisory.

But the danger on Sept 8 wasn't one package. It was one person's account. The better question is:

Who can push code into my app, and how much of it can each of them reach?

No advisory database can answer that, because the answer isn't a fact about a package. It's the shape of your dependencies: you → your 90 direct dependencies → a thousand hidden ones → the hundreds of people who can publish them.

That's a graph question. So for Graph Hacks I built Chokepoint on FalkorDB.

The graph

Chokepoint reads your package-lock.json, asks npm who maintains each package and asks OSV about advisories. Then it builds one FalkorDB graph per project:

(Project)-[:DEPENDS_ON {range, dev}]->(Version)
(Version)-[:DEPENDS_ON {range}]->(Version)
(Version)-[:VERSION_OF]->(Package)
(Maintainer)-[:MAINTAINS {current, at_install}]->(Package)
(Maintainer)-[:PUBLISHED]->(Version)
(Advisory)-[:AFFECTS]->(Version)
Enter fullscreen mode Exit fullscreen mode

For the replay I used webpack-dev-server's real lockfile from Aug 25, 2025, two weeks before the attack: 1,423 versions, 90 direct dependencies, 685 npm accounts, 3,108 links.

(One rule I kept from day one: maintainers are stored by npm username only. Emails are dropped the moment they're read.)

"Who could poison me?" in one query

Here's the heart of it. For every npm account: how many of my direct dependencies eventually pull in a package that account can publish?

MATCH (p:Project)-[r:DEPENDS_ON]->(d:Version)
CALL algo.BFS(d, -1, NULL) YIELD nodes
UNWIND nodes AS pkg
WITH r, d, pkg WHERE pkg:Package
MATCH (pkg)<-[m:MAINTAINS]-(u:Maintainer)
WHERE ($when = 'now' AND m.current) OR ($when <> 'now' AND m.at_install)
WITH u, d, r
RETURN u.username AS maintainer,
       count(DISTINCT d) AS reach,
       count(DISTINCT CASE WHEN r.dev THEN NULL ELSE d END) AS prod_reach
Enter fullscreen mode Exit fullscreen mode

On the replay project, @qix comes out at 43 of 90 direct dependencies. One phished login could reach almost half the app.

Ties are broken with algo.betweenness (accounts sitting on the bridges many paths pass through) and algo.pageRank (how influential the versions they control are).

The lesson that cost me an evening: BFS, not [:DEPENDS_ON*]

My first version used a variable-length pattern, (d)-[:DEPENDS_ON*]->(pkg). It looked elegant and timed out.

A variable-length match walks every possible path, and real dependency graphs have exponentially many of them (diamonds everywhere: a → b → d and a → c → d…). algo.BFS visits each node once. Same answer, and it actually returns.

The opposite turned out to be true for "how many things depend on this one version?": a single walk backwards with count(DISTINCT …) was about 10× faster than a BFS from every node. Graph work is a lot of measuring.

Meet Nightjar 🐦

Knowing who could poison you is half the story. The other half is noticing when someone does.

Nightjar is Chokepoint's watcher agent (a nightjar is a night bird with huge eyes that hides in plain sight). Every minute it looks for new releases of your app's packages, and for each one it runs a loop:

watch → investigate (in the graph) → decide → act → remember

The decision is plain rules, no AI, because a security verdict should be explainable and checkable:

Signal Points
A fresh install would take it (inside your declared ranges) +3
A burst: 3+ releases from one account within 10 minutes +3
A new install script +3
Publisher isn't in the package's maintainer list +3
Provenance dropped +2
Publisher reaches much of your app +1
The account already triggered alerts (memory) +1

3 points is a WATCH, 6 an ALERT.

Replaying npm's real publish times from Sept 8:

  • 13:12:10 ansi-styles@6.2.2: WATCH
  • 13:12:39 debug@4.4.2: WATCH
  • 13:13:05 chalk@5.6.1: ALERT. Inside 6 of the app's ranges (+3), the third release from @qix in 54 seconds (+3), from an account reaching 43 of 90 dependencies (+1). Score 7.

The first advisory arrived at 14:26. Nightjar would have raised its alert 73 minutes earlier, without knowing anything about malware: just from the shape of the graph and the rhythm of the releases.

And on an ALERT it acts: it pins the version your lockfile already had (only for the poisoned release) under overrides in package.json, can open a fix PR on your repo, and notify you on the desktop, Slack or Discord.

chokepoint nightjar --drill --act in a terminal

Memory, in the graph too

This was the Track 02 part, and my favourite design decision: Nightjar's memory is a FalkorDB graph of its own, one per project (memory:<project>):

(Alert)-[:ABOUT]->(Package)
(Alert)-[:PUBLISHED_BY]->(Maintainer)
(Decision)-[:BASED_ON]->(Alert)
Enter fullscreen mode Exit fullscreen mode
  • It lasts: rebuilding a project's graph never touches its memory.
  • It changes: open alerts are re-checked against OSV every cycle and move to CONFIRMED or CLEARED, each move a new Decision, so the history stays.
  • It feeds the next decision: earlier alerts on an account raise the score of its next release. On Sept 8, every release after chalk carried @qix already triggered N alert(s).
  • It's shared: Nightjar writes it; a second agent reads it.

Any AI can use it: MCP

Every question Chokepoint answers is a function in one file, tools.py, and the same functions are served by an MCP server. The website's "Ask" box is an MCP client of that server: your LLM picks a tool from the list, the call goes through MCP, and the graph computes the answer. The LLM only translates it into plain words, and it never writes Cypher.

So "what did Nightjar flag?" picks the recall tool, reads Nightjar's memory graph and lights up every flagged package on the graph. No LLM? Keyword rules pick the tool instead. Nothing in Chokepoint needs AI.

(A bug I found while recording the demo: my MCP server stripped the "what to light up" field from every result to keep it lean for outside clients, so the website's own Ask answers never lit up the graph. Fixed by letting Chokepoint's own agent ask for it. Recording a demo is the best QA I know.)

Replay vs the real thing

The website is a replay of Sept 8 on real data: npm's real publish times, OSV's real advisory times, a real lockfile. At the end it even opens a real pull request on a sandbox fork, and Nightjar leaves a note for each visitor.

But the real product runs on your machine:

pipx install --python 3.12 ckpoint
cd your-app
chokepoint            # builds the graph from package-lock.json
chokepoint who        # who can poison you, ranked by reach
chokepoint check debug@4.4.2
chokepoint nightjar --drill --act   # the real Sept 8 releases vs YOUR app
Enter fullscreen mode Exit fullscreen mode

It uses an embedded FalkorDB, so there's no Docker, no account and no server, and nothing leaves your laptop. If you plug in an LLM or a GitHub token, they stay on your machine too. That one mattered to me more than any feature.

What I'd tell myself on day one

  1. Ask the question your tools can't. The interesting security question wasn't "is it bad?" but "who could make it bad?", and only a graph could answer it.
  2. Let the graph reason; keep the AI as a translator. Explainable rules for the verdict, Cypher for the facts, the LLM for the words.
  3. Measure your traversals. [:DEPENDS_ON*] vs algo.BFS was the difference between a timeout and an instant answer.
  4. Memory belongs in the graph. Alerts, decisions and accounts are relationships, so storing them as a graph made "has this account done this before?" a one-hop question.
  5. Record your demo early. It found three real bugs for me, including one in the published CLI that only showed up on an older Typer version.

Try it

@qix didn't do anything wrong: their account was phished, and it could have been anyone's. The point isn't blame. It's knowing, before it happens, how much of your app sits behind one login.

Built for Graph Hacks (WeMakeDevs × FalkorDB), Tracks 01 + 02.

Top comments (0)