How it works

Reachable reads public sources, writes one graph into HydraDB, and answers the six incident questions by traversing that graph. Nothing on a report card is computed in Python from rows the database handed back: the walk happens in the engine, and the executed statement is printed under the answer. This chapter follows the data from source to verdict, and ends with what the engine turned out to be.
The pipeline
Live: the Graph page shows the same counts and the ingest jobs that produced them.
Watching a repository is a four-step job in the worker (worker/reachable/pipeline.py):
- Lockfiles — GitHub's commit history for
package-lock.json(npm lockfileVersion 2 and 3) orpnpm-lock.yaml(pnpm 6.x and 9.x) at the repository root. Every commit becomes aLockfilenode stampedcommitted_at; the flattened install tree the package manager wrote becomesRESOLVEDedges toVersionnodes, and each entry's own dependencies becomeDEPENDS_ONedges. yarn and bun lockfiles are refused, not guessed. - Packages — for every package the lockfiles mention,
registry.npmjs.orggives versions, publish times and maintainers, andapi.npmjs.orggives weekly downloads. The registry'stimemap keeps a version's publish timestamp after the artifact is erased, which is howVersion.removedand an exactlive_fromare known. - Advisories — OSV.dev records (
MAL-*,GHSA-*,CVE-*) whose affected ranges are expanded against the versions actually in the graph. Each match is anAFFECTSedge that carries the installable window. - Import scan — first-party JavaScript and TypeScript at the exposed commit, read through
the GitHub tree API and matched against import and require forms. Matches become
Filenodes withCONTAINSandIMPORTSedges.
A fifth stage, run over the whole corpus, materialises NAME_SIMILAR_TO edges between packages
whose names sit within a small edit distance, so look-alike lookup is a traversal later rather
than a scan.
The graph the guide's numbers come from holds 13 services,
246 lockfile snapshots, 6,446
packages, 55,507 versions, 657
advisories and 3,028 maintainers. All writes are idempotent
MERGEs keyed on a deterministic 52-bit hash of the human key, so re-running a job changes
nothing that was already there.
The graph model
Seven ingested labels, nine relationship types (the fixture-only Symbol edges are not
drawn); the frozen definition is in
Graph schema. Three details carry most of the weight:
- Ids are integers, keys are strings. HydraDB requires non-negative integer ids for nodes
and relationships. Every node stores its purl-shaped human key (
pkg:npm/debug@4.4.2,svc:owner/repo,lock:owner/repo@sha) inkey; the id isblake2b(key) >> 12, 52 bits so that JSON in the browser never loses precision. Relationships mirror their id intoeidbecauser.idis not usable inWHEREorRETURN. - The installable window lives on
AFFECTS.live_fromis the version's publish time, exact.live_tois the earlier of the next surviving publish and the advisory's own publish time — anupper bound, because npm publishes no takedown time;live_to_kindsays which kind of bound it is (upper_bound,unboundedfor CVEs and unbounded malware, andexactis reserved and never written today). A version hit by two advisories has two windows; only an edge can hold that. RESOLVED.atis the lockfile's commit time, copied onto the edge so the while-live test is one comparison between two edge properties and never needs a second hop.
NAME_SIMILAR_TO carries kind (scope, hyphen, homoglyph, prefix_suffix, insertion,
deletion, transposition, substitution, edit2) and distance (1 or 2). Timestamps are
integer epoch seconds throughout: the engine has no date functions and refuses to compare a
string against an integer.
Q1 — which services are transitively exposed
Because RESOLVED is the flattened install tree, transitive membership is exact in one hop:
any lockfile with a RESOLVED edge to an affected version resolved it, however deep the package
sat in the tree. The membership statement:
MATCH (bad:Version {id: 4277814107888805})<-[r:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) RETURN sv.key AS service, l.key AS lockfile, l.id AS lid, l.committed_at AS committed_at, l.sha AS sha, r.at AS resolved_at ORDER BY l.committed_at DESC
CALL algo.SPpaths({sourceNode: $src, targetNode: $dst, relTypes: ['DEPENDS_ON', 'RESOLVED'], relDirection: 'incoming', maxLen: 9, pathCount: 3}) YIELD path RETURN pathThe first statement lists services and lockfiles; the algo.SPpaths calls that follow ask, per
lockfile, for up to three shortest chains of DEPENDS_ON and RESOLVED edges from the affected
version back to the lockfile — the proof that the report shows as
debug@4.4.2 ← DEPENDS_ON ← eslint@8.57.1 ← RESOLVED ← lockfile. Paths come back from the
engine; the worker never reconstructs them.
For the many-to-many form — every affected version against every watched service — one
algo.MSpaths call does the whole fan-out:
CALL algo.MSpaths({sourceLabel: 'Version', sourceProperty: 'key', sourceValues: ['pkg:npm/debug@4.4.2'], targetLabel: 'Service', targetProperty: 'key', targetValues: ['svc:ChrisTregaskis/ai-research-automation', 'svc:GoMake-ltd/n8n-node-gomake', 'svc:Kong/insomnia', 'svc:LVQT-ss/cakestory-api', 'svc:documenso/documenso', 'svc:koajs/koa', 'svc:louislam/uptime-kuma', 'svc:lperry65/Aider-Chat', 'svc:medplum/medplum', 'svc:socketio/socket.io', 'svc:twbs/bootstrap', 'svc:usebruno/bruno', 'svc:wagtail/wagtail'], relTypes: ['RESOLVED', 'HAS_LOCKFILE'], relDirection: 'incoming', maxLen: $maxlen, pathCount: $pathcount, resultLimit: $limit}) YIELD path RETURN pathMeasured for this incident: the membership query and its algo.SPpaths proofs returned
6 exposed lockfiles across
3 services in 888.20 ms cold
and 7.06 ms warm (median of
5 runs, p95 7.18 ms). The
MSpaths call over 1 source and 13
targets returned 3 paths in 699.07 ms
cold and 0.72 ms warm. Cold is the first run after the node
was idle; warm is every run after. Both are reported and neither is estimated.
Q2 — which version introduced it
MATCH (a:Advisory {id: 2971413083072216})-[:AFFECTS]->(v:Version) RETURN v.key AS version, v.published_at AS published_at ORDER BY v.published_at ASC LIMIT 1
MATCH (a:Advisory {id: 2971413083072216})-[af:AFFECTS]->(v:Version)-[:VERSION_OF]->(p:Package) RETURN p.key AS package, v.key AS version, v.published_at AS published_at, v.removed AS removed, af.live_from AS live_from, af.live_to AS live_to, af.live_to_kind AS live_to_kind ORDER BY v.published_at ASCThe engine has no min(), so the first affected version is ORDER BY v.published_at ASC LIMIT 1.
The second statement returns every affected version with its publish time, the removed flag
and the window from the AFFECTS edge. removed is true when the registry still lists a
publish time for a version that is no longer in its versions map — proof that npm erased it,
not of why. For this incident the first affected version is
pkg:npm/debug@4.4.2, and the statement ran in 9.05 ms.
Q3 — which apps resolved it while it was live
This is the question that a lockfile grep cannot answer. Both timestamps it needs already sit on edges, so the whole test is one predicate in the engine:
MATCH (a:Advisory {id: 2971413083072216})-[af:AFFECTS]->(v:Version)<-[r:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) WHERE r.at >= af.live_from AND r.at <= af.live_to RETURN sv.key AS service, l.key AS lockfile, l.sha AS sha, r.at AS resolved_at, v.key AS version, v.removed AS removed, af.live_from AS live_from, af.live_to AS live_to, af.live_to_kind AS live_to_kind ORDER BY r.at ASC
MATCH (a:Advisory {id: 2971413083072216})-[af:AFFECTS]->(v:Version)<-[r:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) WHERE v.removed = true RETURN sv.key AS service, l.key AS lockfile, l.sha AS sha, r.at AS resolved_at, v.key AS version, v.removed AS removed, af.live_from AS live_from, af.live_to AS live_to, af.live_to_kind AS live_to_kind ORDER BY r.at ASCTwo evidence classes come back, and the report labels each row:
- in window — the lockfile's
RESOLVED.atfalls betweenlive_fromandlive_to. It proves the lockfile pinned the version while it was installable; it does not prove an install ran on any machine. - pins removed — the second statement: the lockfile pins a version npm has since erased.
That is only possible while the version was live, so commit time is irrelevant, and this
class survives even when
live_tois loose.
Because live_to is an upper bound, an in-window commit near the end of the window may in
truth have happened after takedown; the report says so on the row rather than tightening the
bound. For this incident: 2 lockfile commits inside the
window and 6 pinning an erased version, in
4.81 ms. Q3 is offered only for malware advisories; for a CVE the artifact
stays on the registry and "while live" collapses into "at all".
Q4 — what else the same maintainers publish
Two hops out from the affected package through its maintainers, then back down through
VERSION_OF, RESOLVED and HAS_LOCKFILE to see which watched services resolve each
co-maintained package today:
MATCH (bad:Version {id: 4277814107888805})-[:VERSION_OF]->(p:Package)<-[:MAINTAINS]-(m:Maintainer)-[:MAINTAINS]->(other:Package) RETURN m.key AS maintainer, m.twofa AS twofa, m.account_created AS account_created, p.key AS bad_package, other.key AS package, other.id AS pid, other.downloads AS downloads
MATCH (p:Package {id: 4380943100042017})<-[:VERSION_OF]-(v:Version)<-[:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) RETURN sv.key AS service, count(*) AS n
MATCH (p:Package {id: 2155802479278732})<-[:VERSION_OF]-(v:Version)<-[:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) RETURN sv.key AS service, count(*) AS n
MATCH (p:Package {id: 2857034762624845})<-[:VERSION_OF]-(v:Version)<-[:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) RETURN sv.key AS service, count(*) AS n
MATCH (p:Package {id: 2996606650714712})<-[:VERSION_OF]-(v:Version)<-[:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) RETURN sv.key AS service, count(*) AS n
MATCH (p:Package {id: 1723152998288652})<-[:VERSION_OF]-(v:Version)<-[:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) RETURN sv.key AS service, count(*) AS n
MATCH (p:Package {id: 3688734141769757})<-[:VERSION_OF]-(v:Version)<-[:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) RETURN sv.key AS service, count(*) AS n
MATCH (p:Package {id: 117786138360580})<-[:VERSION_OF]-(v:Version)<-[:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) RETURN sv.key AS service, count(*) AS n
MATCH (p:Package {id: 3127339569977186})<-[:VERSION_OF]-(v:Version)<-[:RESOLVED]-(l:Lockfile)<-[:HAS_LOCKFILE]-(sv:Service) RETURN sv.key AS service, count(*) AS nThe fan-out statement lists 32 co-maintained packages for this incident. Exposure is then
computed for the eight most-downloaded of them, one statement per package with count(*)
grouped by service; the remaining packages are listed with their download counts and read
— not computed. That is a stated cap, not an approximation: on a prolific maintainer the full
set takes tens of seconds, and this incident's Q4 took 13.60 s as it
stands. "Services at risk" here means services that resolve the co-maintained package now — the
exposure if that package is compromised next, not exposure to this incident. twofa and
account_created are requested but the public registry does not expose them; they render as
unknown.
Q5 — which look-alike names exist
Near-name proximity is materialised at ingest as NAME_SIMILAR_TO edges from a suspect
package to a popular one, so at question time the lookup is a one-hop traversal from the
affected package with distance and kind read off the edge, joined to the suspect's
maintainers:
MATCH (suspect:Package)-[sim:NAME_SIMILAR_TO]->(popular:Package {id: 171969907551371}) WHERE sim.distance <= $maxd MATCH (suspect)<-[:MAINTAINS]-(m:Maintainer) RETURN suspect.key AS package, suspect.downloads AS downloads, sim.distance AS distance, sim.kind AS kind, m.key AS maintainer, m.account_created AS account_created, m.twofa AS twofa ORDER BY sim.distance ASC, suspect.downloads ASCIt ran in 2.60 ms. Distance and kind are facts; "typosquat" is a hypothesis.
A scope neighbour such as @types/debug is a legitimate package that happens to sit one edit
away, and the report shows it with the same chip as anything else — the reader, not the graph,
decides. Candidates come only from the ingested corpus, so a look-alike that no watched
lockfile ever pulled in is not in the graph and cannot be listed.
Q6 — the blast radius, and what is actually reachable
Live: Q6 on the report · the board.
Q6 is the composition: worker/reachable/incident.py runs Q1 to Q5 in one pass, records the
statement, row count and wall-clock milliseconds of each, and writes the JSON the report renders
(worker/out/<advisory>.json). Total for this incident, Q4 included:
16.45 s.
The verdict on each exposed service comes from the reachability scan:
- L2 act now — first-party code references the vulnerable symbol the advisory names. No
ingest stage writes
Symbolnodes today; L2 exists in the test fixture and is claimed only when an advisory names a symbol and the scan finds it. - L1 imported — a first-party file has an
IMPORTSedge to the affected package. - L0 present only — the package is in the install tree and no scanned file imports it.
- unscanned — the service is exposed but no
Filenodes exist for it. It is styled as unknown, never as safe, and never counted as zero.
What the scan does: lists JavaScript and TypeScript files at the exposed commit (skipping
node_modules, build output and vendored directories, up to a per-repository file cap), reads
them, and matches import … from, bare import, require(...), dynamic import(...) and
export … from against the packages the advisory names, mapping subpath imports to their
package. What it does not prove: it is a regex over source text, not a parser, so it cannot tell
a call from a mention, cannot follow re-exports, and says nothing about code paths at runtime.
An L0 verdict means "not imported by any scanned file"; it is not a clean bill. For this
incident the three exposed services scanned 3 at L0,
0 at L1, 0 at L2 and
0 unscanned; the per-service file counts and statements are on the
report card.
What we learned about the engine
Every item below was verified against a running node with make probe; the full list is in
AGENTS.md.
- Node and relationship ids must be non-negative integers, so purls are hashed and the human
key lives in a
keyproperty; relationships need their own id, mirrored intoeid. - There is no DDL:
CREATE INDEXis rejected in every form andgraph-indexerindexes properties on write — a fresh property worked as a selector immediately. - All property writes go through
UNWIND $rows AS row …, hard-capped at 1024 rows per statement (the loader batches at 1000); plainMERGE … SETis refused, andSETvalues must read from the row map. WHEREcompares property against property across nodes and relationships, which is what makes Q3 one predicate — but operands must share a type family, a missing property silently drops the row, andWHEREevaluates no arithmetic.RETURNsupports bound properties,count(*),sum,avgandcollectand nothing else: no literals,CASE,coalesce,minormax.ORDER BY … LIMIT 1stands in formin/max; anything the console shows must be a stored property.- Bounded variable-length patterns work up to 16 hops, but the source must be an inline integer
literal and an incoming var-length needs a second pattern segment;
MATCH p = …andlength(p)are refused, so hop counts come fromalgo.*results. algo.MSpaths/SSpaths/SPpathsare complete standalone queries: nothing may followYIELD path RETURN path, so filtering happens client-side.relDirection: 'incoming'works.pathCountdefaults to 1 andresultLimittruncates silently, so the helper always setspathCountand requests one more row than it will show.sourceValues, labels and relationship types must be inline literals — a Cypher-injection surface fed by registry data, closed by a strict allowlist that rejects rather than escapes.UNIONworks, but a trailingORDER BY/LIMITapplies to the last arm only, and an N-armUNIONcosts the same as N statements; Q3's per-version loop stays a loop.