Why a graph

Reachable — one traced route through a stack of packages
Reachable — one traced route through a stack of packages

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.

  1. 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).
  2. 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.
  3. Which apps resolved it while it was live? Not "do we depend on debug" but "did a lockfile pin 4.4.2 between the publish and the takedown". That is one predicate comparing two edge properties: the commit time on RESOLVED against the window on AFFECTS.
  4. 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.
  5. 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.
  6. 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.
Six questions on one graph: advisory MAL-2025-46974 affects debug@4.4.2, resolved by lockfile cakestory-api@458e59e of service LVQT-ss/cakestory-api; debug is maintained by qix who also maintains color-convert; @types/debug is a near name; each question is a numbered walkAdvisoryMAL-2025-46974Versiondebug@4.4.2Lockfilecakestory-api@458e59eServiceLVQT-ss/cakestory-apiAFFECTSlive_from · live_to · live_to_kindQ2RESOLVEDHAS_LOCKFILEQ1at ∈ [live_from, live_to]Q3Q6reachable · act nowimportedpresent onlyunscannedMaintainerqixPackagedebugPackage@types/debugVERSION_OFMAINTAINSQ4NAME_SIMILAR_TOkind = scope · distance = 1Q5Packagecolor-convertMAINTAINS→ the services resolving color-convert today, per package, in one statementQ1who is exposedQ2which versionsQ3resolved while liveQ4maintainer fan-outQ5near namesQ6verdict

What breaks without the graph

  • Question 3 is a bitemporal join per affected version in SQL. Here it is one WHERE comparing two relationship properties, because the installable window lives on the AFFECTS edge and the lockfile commit time lives on the RESOLVED edge. 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 ← lockfile is a path returned by algo.SPpaths; the console never reconstructs paths from rows. Membership over the flattened RESOLVED closure, 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.MSpaths takes 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 MERGE and 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 boundlive_to is 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 is unscanned. 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.