Rohit-ATS/blast-radius: npm supply-chain incident response over a graph database. Built on HydraDB for Hack Hydra. - Rohit-A

github.com · 2026-08-22 · by Long Nguyen ·github
npm supply-chain incident response over a graph database. Built on HydraDB for Hack Hydra. - Rohit-ATS/blast-radius

Rohit-ATS/blast-radius

npm supply-chain incident response over a graph database. Built on HydraDB for Hack Hydra. - Rohit-ATS/blast-radius

2 stars · 0 forks

README

Blast Radius When an npm package is compromised, find out who is actually exposed — before anyone opens an advisory.

Try it here: https://blast-radius-web-zc8b.onrender.com/ Four questions matter during a supply-chain incident, and none of them is "which packages are similar to this one":

Question Answered by

1 Who is transitively exposed? Everything that pulls it, five levels down. one traversal from a known vertex

2 Whose semver range would actually have pulled the poison? Listing a dependency is not the same as resolving to the bad version. every declared range, evaluated

3 Is anything in my project already malicious? live against osv.dev, no crawl coverage needed

4 How do I fix it? the safe version, an overrides block, a brief an agent can act on

docker compose up -d # HydraDB cp .env.example .env # optional: Supabase, SMTP — see SETUP.md py setup_check.py # verifies every credential against the real service py server.py # http://127.0.0.1:8000

What you get Five surfaces, one port, no build step. Every number on every one of them comes back from a query that was actually run.

Route What it is

/ The story, with a live incident chart measured on page load

/check The console — blast radius, semver split, lockfile check, OSV audit, graph explorer, publish feed

/developers The API — key vault, quickstarts, a playground that sends real authenticated calls, the full reference

/dashboard Your account — monitors, alerts, keys, notifications, security log, all live over SSE

/signin Sign in / create an account

The console Name a package and a bad version. This walks the graph five hops out, resolves every declared range against that version, and tells you which of the two numbers actually matters.

The radius, drawn. Concentric rings by depth, red attenuating outward. Click any package to pivot the whole console onto it. Measured, not claimed. Every panel carries the latency of the query that produced it.

The API Free, no rate limit, no quota, no card. The same code path the console runs, behind a stable /api/v1 contract.

curl -H 'Authorization: Bearer brk_live_...' \ 'http://localhost:8000/api/v1/blast?name=debug&depth=5' { "total": 3834, "depth": 5, "queries": 6, "histogram": [{ "depth": 1, "packages": 744 }, ...], "latency_ms": 1492.0, "source": "hydradb", "ok": true } The reference is generated from the same table the router reads, so it cannot drift — and it is served three ways so an agent can consume it directly:

/api/docs.json the reference as data

/api/docs.md Markdown, written to paste into an AI agent's context

/api/docs.txt plain text

/api/docs OpenAPI / Swagger UI

Keys are never stored. What is persisted is a SHA-256 digest and a short non-secret prefix; a copy of the database yields nothing usable. The plaintext is shown once, at creation, and lives only in the creating tab's memory. 24/7 monitoring Register a package and this instance keeps measuring it. When its blast radius moves, an alert reaches your dashboard, your webhooks, and your inbox.

curl -X POST http://localhost:8000/api/v1/monitors \ -H 'Authorization: Bearer brk_live_...' \ -H 'Content-Type: application/json' \ -d '{"package":"debug"}' Deliveries are signed the way Stripe and GitHub sign theirs: X-BlastRadius-Signature: t=1787270000,v1=9f86d081884c7d65...

HMAC-SHA256 over <t>.<raw body>. The timestamp is inside the signed material, so a captured payload cannot be replayed. Three attempts with backoff; an endpoint that fails twenty times in a row is disabled rather than retried forever. Accounts are local by default (PBKDF2-HMAC-SHA256, 310k rounds) or Supabase — two lines in .env, nothing else changes. See SETUP.md for exactly where each credential goes, and PLATFORM.md for how the platform layer is built.

Why this is a graph problem The question in an incident is reachability: who depends on the compromised package, who depends on them, and so on. That is a transitive closure over a directed graph. A vector index cannot express it at all. "Packages similar to event-stream" is not a question anyone asks while their build is shipping a crypto miner. But the argument is not really about one traversal. It is about chaining across relationship types. "Which packages does qix maintain" is a join. "How many packages transitively depend on anything qix maintains" crosses two relationship types and changes answer the moment any edge anywhere in the graph moves. That is a graph question, and it is the one that matters after a maintainer gets phished. Everything is a node

(:Package {id, name, latest}) (:Maintainer {id, name}) (:Advisory {id, osv_id, severity, is_malware, summary})

(dependency)-[:REQUIRED_BY]->(dependent) 91,544 (:Maintainer)-[:MAINTAINS]->(:Package) 5,184 (:Package)-[:MAINTAINED_BY]->(:Maintainer) 5,184 (:Advisory)-[:AFFECTS]->(:Package) 138 (:Package)-[:HAS_ADVISORY]->(:Advisory) 138 (:Package)-[:SIMILAR_TO]->(:Package) 270

37,237 packages · 128,228 dependency edges, and still growing — the crawler runs continuously, so these are a snapshot rather than a fixed size. /api/stats is the live count. Ids are namespaced through nid() — nid("maint:qix"), nid("adv:MAL-2025-46974") — so three entity kinds share one integer id space without colliding. Every relationship is stored in both directions. That is not redundancy: a variable-length MATCH requires a fixed source id, so without the reverse edge there is no way to ask "who maintains this package" starting from the package. The chained traversals Each stage is anchored at a known vertex because the engine requires it, and the stages run concurrently. 1. What one compromised account can reach MATCH (m {id: $maintainer_id})-[:MAINTAINS]->(p) RETURN p.name -- then, per package, concurrently: MATCH (t {id: $package_id})-[:REQUIRED_BY*1..4]->(v) RETURN count(*)

qix controls 2 packages. 3,484 packages depend on them. — 803 ms

The union is counted, not the sum: packages by one author share most of their downstream, and adding the counts double-counts it. The September 2025 chalk/debug attack began with exactly one account. 2. Why am I exposed — the actual chain GET /api/why-exposed?from=debug&to=express The depth at which the target becomes reachable is computed in the graph and is authoritative. The concrete path cannot be — HydraDB 0.1.0 returns no path binding — so it is rebuilt from the sidecar edge table and then every hop is re-confirmed against the graph, with graph_verified reported honestly. 3. Blast radius of a CVE, not a package MATCH (a {id: $advisory_id})-[:AFFECTS]->(p) RETURN p.name -- then REQUIRED_BY outward from each affected package An advisory usually names several packages. "How far does GHSA-xxxx reach" is a different question from asking about any one of them. 4. Typosquats that already have victims MATCH (p {id: $package_id})-[:SIMILAR_TO]->(q) RETURN q.name -- then, per neighbour: how many packages already depend on it A near-miss name nobody uses is trivia. One with real dependents means somebody has already installed the wrong thing. The reversed-edge decision Edges are stored backwards: (dependency)-[:REQUIRED_BY]->(dependent). express depends on debug, so the natural edge is express -> debug. We store debug -> express. The reason is a hard constraint: "variable-length MATCH requires a fixed source id"

With natural edges, "who transitively depends on debug" means starting from every possible dependent — an unbounded set of sources, which the engine refuses. Reversed, the traversal starts at debug, and in an incident the compromised package is the one thing you know for certain. The write path pays a trivial cost (swap two fields); the read path becomes one legal traversal instead of an illegal scan. The radius, drawn Concentric rings by dependency depth, red attenuating outward. A node's ring is a real property of the graph — the depth HydraDB first reaches it at — not a layout convenience. Click any node and the whole console pivots onto it.

Two layers, on purpose

layer store holds

topology HydraDB packages, maintainers, advisories, every edge between them

predicates SQLite (deps.db) declared semver ranges

Only semver ranges stay out of the graph, and that is forced — HydraDB 0.1.0 cannot filter on edge properties during a traversal: "variable-length relationship bindings are not executable in Query engine"

A range stored on an edge would be unreadable at exactly the moment it matters. What is live, and what is crawled Being precise about this matters more than sounding impressive.

capability coverage source

Lockfile audit — is anything in my tree malicious all of npm osv.dev, live

Package intel — real, current, compromised all of npm registry + osv.dev, live

Remediation — safe version, overrides, agent brief all of npm osv.dev, live

Typosquat existence check all of npm registry, live

Live publish feed npm, PyPI, crates.io, Go, Maven all five change feeds, polled

Blast radius / chained traversals 31,500+ packages, growing live HydraDB graph

Project monitoring + alerts any lockfile, 5 ecosystems HydraDB traversal

The universal features need no crawl — your lockfile is your tree. The graph powers the differentiated layer on top. Every API response carries graph_coverage, so the caveat travels with the data instead of living in a footnote. Drop a real lockfile and every resolved package goes to OSV. Expanding a finding loads the fix inline — the safe version, the overrides block that forces every transitive copy, and a self-contained brief for a coding agent:

The graph writes itself ingest.py is a batch crawl: it walks a frontier, fills the graph, and stops. That is fine for building a snapshot and wrong for a tool whose entire claim is answering who is exposed right now. A blast radius computed against a graph written thirteen hours ago describes a dependency tree that has since changed. So live.py runs all five registries' change feeds continuously and writes what they report straight into HydraDB — one poller per registry, one writer, per-registry backoff. GET /api/live/status reports what is actually happening rather than what is supposed to be:

registry polled every source

npm 5s replicate.npmjs.com/_changes?since=

PyPI 15s the RSS newest-packages feed

Go 20s index.golang.org/index?since=

crates.io 30s the summary endpoint

Maven 60s Central search, sorted by publish timestamp

A registry having a bad afternoon backs off exponentially and says so; it does not take the other four down, and it does not sit there looking green. An ecosystem that has never seen a publish reports last_event_at: null rather than a plausible-looking timestamp. Growth is budgeted, and that is the interesting part. Continuous ingestion adds roughly 21,000 edges an hour, and this graph stops answering depth 4 and 5 at all somewhere around 246,000 edges — the same cliff that broke an earlier phase. Left alone, live ingestion would have destroyed the thing it was feeding, inside an afternoon, and the failure would have looked like HydraDB being slow rather than like a crawler with no brakes. Past BLAST_EDGE_BUDGET the crawler keeps refreshing packages it already knows — the security-relevant work, which adds almost no vertices — and stops discovering packages nobody depends on yet. Coverage of the whole registry was never the goal. One honest consequence: the d+1 traversals behind a blast radius run concurrently against a graph that is being written to, so they do not observe the same instant. When nothing is truncated the enumerated victim list is treated as ground truth and the counts are clamped to it, so the histogram always sums to the headline instead of shipping a visible off-by-one. Monitoring: the traversal is the alert router Register a lockfile and it becomes a Project vertex with one REQUIRED_BY edge per installed package — the same edge direction the dependency graph already uses, deliberately. "Who do I wake up about this publish" then stops being a fan-out over a subscriber table and becomes the query this database exists for: MATCH (p:Package {id: $published})-[:REQUIRED_BY*1..N]->(t:Project) RETURN t.pid One traversal from the package that just changed, and out comes the exact set of affected projects. Measured at 6–35ms. Exact versus inferred. A lockfile is the resolved tree — everything installed is named in it — so every package becomes a direct edge and depth 1 is a complete and precise answer. A manifest (package.json, pyproject.toml, pom.xml) names only direct dependencies, so the rest is reached by traversing the crawled graph and is exactly as complete as our coverage of it. Those are different claims, and every alert says which one it is. Fourteen project-file formats, detected by filename and then by content:

ecosystem exact (resolved tree) inferred (manifest)

npm package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml package.json

PyPI poetry.lock, Pipfile.lock, uv.lock, pinned requirements.txt pyproject.toml, ranged requirements.txt

crates.io Cargo.lock Cargo.toml

Go go.sum go.mod

Maven gradle.lockfile pom.xml, build.gradle

A ranged requirement records an empty version and downgrades the project to inferred — never the range text stored where a resolved version belongs. That is the same rule as satisfies(): listing a dependency and pulling a specific version are different facts. Wiring it into a real project

register once — the token is shown once and is not recoverable

curl -X POST "http://localhost:8000/api/watch/register?name=payments&filename=package-lock.json&webhook=https://hooks.example.com/blast" \ --data-binary @package-lock.json

{"project_id":"gFrLfOML7nFr","token":"…","watching":412,"precision":"exact","depth":1}

Then take alerts whichever way suits the system:

poll — `since` is the last id you handled, so you get what is new and nothing twice

curl "http://localhost:8000/api/watch/$ID/alerts?token=$TOKEN&since=$CURSOR&min_severity=high"

or hold a stream open; a heartbeat every 15s distinguishes quiet from dead

curl -N "http://localhost:8000/api/watch/$ID/stream?token=$TOKEN" Or point the webhook at an n8n Webhook node and route from there — an alert is a flat JSON body with severity, kind, package, version, hops, precision and the OSV advisories attached, which is enough to branch on without any further lookups. A webhook that fails is retried with backoff and its failure shows up on GET /api/watch/{id} as webhook_failures and last_webhook_error. An alert is written to SQLite before delivery is attempted, so it is never lost — an alert nobody received is worse than one that arrived late. Severity comes from asking OSV about the exact version that was just published, in that package's own ecosystem. A version-less question returns every advisory ever filed against the package, which would make a routine release of anything with history look like an incident.

severity when

critical OSV classifies the new version as malware

high / medium it carries a real advisory

info a clean publish — still the event you want, and the only warning that exists before an advisory does

HydraDB 0.1.0: constraints we hit and engineered around The organizers asked people to surface what does not work. Everything here was found empirically; probe_constraints.py prints a PASS/FAIL table and flags any row where a constraint no longer holds. A list in a README is a claim, so the running server publishes it as a measurement instead. /constraints re-derives this entire section against the live database every five minutes — 25 probes, each with the query it ran and what came back. The silent-failure traps below are shown twice: the query that lies, beside the query that does not, with both results as they came back seconds ago. If a constraint ever stops holding, the page says SURPRISE and shows what it got instead, because a page that can only agree with itself is not evidence of anything. Rejected outright

what we tried what HydraDB said what we did instead

string vertex ids field id must be a non-negative integer nid() derives a 51-bit int from the name

CREATE INDEX expected query, got CREATE INDEX no index needed — ids are derived

MATCH … MERGE in UNWIND UNWIND MATCH must end in RETURN or DELETE CREATE with explicit ids

bare MERGE … SET MERGE with following clauses is not executable every write goes through UNWIND

length(path) RETURN supports <binding>.<property> or count(*) difference count(*) per depth

var-length MATCH, no fixed source requires a fixed source id reversed edges

edge property filters mid-traversal relationship bindings are not executable ranges live in SQLite

bare MATCH (n) requires an id, label, or property predicate always scope by label or id

count(DISTINCT x) DISTINCT aggregate arguments are not executable RETURN DISTINCT + count client-side

*1..$depth as a parameter requires an explicit max hop interpolate a clamped integer

result limit > 100,000 query_result_limit rejected by admission control clamp to RESULT_LIMIT

feed=continuous (npm, not HydraDB) 400 Bad Request poll _changes?since=<seq>

The ones that fail silently — far more expensive 1. An id-only MATCH can never say "no". MATCH (p {id: $id}) RETURN p.name returns a row of nulls for an id never written. Scoping to :Package makes it an existence test. Until we found this, every unknown package reported an empty blast radius — which reads as safe, the worst possible way to be wrong. 2. {"type": "null"} has no value key. Other typed values are {"type": "string", "value": "debug"}. A client that unwraps only two-key dicts leaves a null as a truthy dict, so an absent property reads as present. This compounded trap 1 exactly. 3. Results page at 1024 rows. Every response carries next_cursor; ignoring it silently truncates every large answer. Continuing a page needs both the cursor and the originating query_id. 4. count(*) on a variable-length match counts reachable vertices, not paths. Good news, and load-bearing: it makes the depth histogram exact and cheap. Verified against a deliberately diamond-shaped graph in probe_counts.py. 5. A restarted store is permanently read-only. SlateDB cannot update an existing manifest on the local filesystem backend: Operation put_opts with mode PutMode::Update not yet implemented. Reads stay perfect; every write returns 500 forever. Only a write reveals it, so /api/health round-trips one on a timer and rebuild.py replays the whole graph from the sidecar in about a minute. 6. /readyz is not a readiness signal for queries. It returns 200 within a second of a restart while deep traversals still fail the engine's own 30-second query ceiling. The server warms itself, walking depths 1→5 in order because each depth caches the pages the next needs. 7. Traversal cost scales with total store size, not the edges walked. This one nearly sank the project. Loading all 77,232 maintainer links (plus the reverse) tripled the store to ~246,000 edges, and depth-4 and depth-5 REQUIRED_BY traversals — which never touch a maintainer edge — stopped completing at all. After capping maintainer edges to packages with ≥10 dependents (~102,000 edges total), depth 5 completes in 860 ms on a cold store, first attempt. There is a practical ceiling here, and it is a property of the store, not of the query. Two accuracy bugs, both erring toward alarm The alarming direction is the dangerous one for a security tool. Classifying malware from advisory prose. Scanning summaries and details for words like "malicious" labelled express as malware, because ordinary vulnerability write-ups describe attacker behaviour in exactly those words. Classification now reads structured fields only: a MAL- id, a malicious-packages-origins record, or CWE-506 (Embedded Malicious Code) — which both

source

github.com · 16 images · 169 links

Comments (0)

No comments yet