Why a graph

Reachable is a supply-chain incident console. When an npm package is compromised, one page answers six questions about your services — from the graph, not from a spreadsheet. This chapter is the argument for the graph; How it works shows each question executing, and Using it walks the console.
The incident, in one paragraph
On 2025-09-08 an npm maintainer was phished and 18 packages with over two billion combined
weekly downloads (chalk, debug, ansi-styles, …) shipped a crypto-clipper. The malicious
versions were installable for about 95 minutes. The advisory the guide uses throughout is
MAL-2025-46974 — "Malicious code in debug (npm)", published 2025-09-08T14:26:51+00:00;
the first affected version, pkg:npm/debug@4.4.2, was published at
2025-09-08T13:12:39+00:00. Every team asked the same six questions, and most
answered them with grep over lockfiles and a spreadsheet. On the watched set of
13 services and 246 lockfile
snapshots, 3 services resolved a compromised version and
3 did so while it was live.
The six questions
Each one is a walk over a graph that changes with every commit — not a scan over a table.
- Which services are transitively exposed? Every watched repository whose lockfile
resolved an affected version, at any commit, with the dependency that pulled it in and the
proving path. A table of direct dependencies cannot answer this; the answer is the chain
bad version ← DEPENDS_ON ← … ← RESOLVED ← lockfile ← HAS_LOCKFILE ← service(see one such chain). - Which version introduced it? Exact publish times from the registry, whether npm has since erased the version, and the installable window — a property of the edge between the advisory and the version, because a version hit by two advisories has two windows.
- Which apps resolved it while it was live? Not "do we depend on
debug" but "did a lockfile pin4.4.2between the publish and the takedown". That is one predicate comparing two edge properties: the commit time onRESOLVEDagainst the window onAFFECTS. - What else do the same maintainers publish? The next blast radius: version → package → maintainer → every other package → the services resolving each today. Two hops out, then the whole exposure walk again, per package, in one statement.
- Which look-alike names exist? Names one edit away, scope confusion, hyphen and homoglyph variants. Proximity is materialised as an edge at ingest, so the question is a one-hop walk from the package rather than a string comparison over every name in the registry.
- What is the complete blast radius? The ledger per service with a verdict — the five walks above composed, plus an import scan that says whether the exposure is reachable from first-party code.
What breaks without the graph
- Question 3 is a bitemporal join per affected version in SQL. Here it is one
WHEREcomparing two relationship properties, because the installable window lives on theAFFECTSedge and the lockfile commit time lives on theRESOLVEDedge. On the guide incident it answers in 4.81 ms: 6 lockfiles pin a version npm has since erased, 2 of them committed inside the window. - Question 1's proof comes back from the engine.
debug@4.4.2 ← DEPENDS_ON ← agent-base@6.0.2 ← RESOLVED ← lockfileis a path returned byalgo.SPpaths; the console never reconstructs paths from rows. Membership over the flattenedRESOLVEDclosure, together with those proof paths, answers in 7.06 ms warm (p50 of 5 runs; 888.20 ms cold, first run after the node was idle). - N affected versions × M services is one call.
algo.MSpathstakes every compromised version as a source and every service as a target in a single traversal — 1 source × 13 targets, 3 paths, 0.72 ms warm. - The graph is the same object the pipeline wrote. 6,446
packages, 55,507 versions, 3,028
maintainers and 657 advisories share one id scheme, so every
ingest is a
MERGEand every question starts from a key, not from a join plan.
Every number above is read from the committed report worker/out/MAL-2025-46974.json
(its provenance footer),
generated 2026-08-16T14:28:33.848965+00:00 against ghcr.io/hydra-db/hydradb@sha256:db78309a233be54662db29744047e985a39b51c45a270d1a1f47c31a62cdb709. Cold and
warm are both reported; neither is estimated.
Verdicts, and the honesty rules
| level | meaning | colour |
|---|---|---|
| L2 act now | first-party code references the affected package's vulnerable symbol | red |
| L1 imported | first-party code imports the package; the symbol is not referenced | amber |
| L0 present only | in the install tree, never imported by any scanned file | green |
| unscanned | exposed, but its source was not read — styled as unknown, never as safe, never counted as zero | grey |
Verdict colours mean their verdict and nothing else. Orange is the only free accent (the verdict distribution on the report).
Three phrases appear verbatim wherever they apply, and are never softened:
upper bound—live_tois an upper bound. npm publishes no takedown time, so the window closes at the earlier of the next surviving publish and the advisory's published time.not computed— a value that was not measured says— not computed; it is never shown as zero. Question 4 computes exposure for the eight most-downloaded co-maintained packages and says so for the rest.unscanned— a service whose source was not read isunscanned. It is never green and never subtracted from the total.
Each answer card carries a hydradb strip: the exact OpenCypher or algo.* statement that
was executed, the row count, and wall-clock latency (cold and warm when both were measured). The
strip can be collapsed but never hidden.
What Reachable is not
- Not an LLM. Questions typed on the Ask page are parsed by a small grammar into one of eight verified statements; you always see which.
- Not a scanner of your source beyond imports (L0/L1). Symbol-level reachability (L2) exists for advisories that name a symbol; it is stated when it is only a hypothesis.
- Not a hosted service. It is single-tenant and self-hosted; whoever runs the node owns the graph.