Cookbook
All recipes
Dependency blast radiusGraph analytics

Agent memory

Search and retrieval

Data quality

Graph analytics

Geospatial

Cookbook / Graph analytics · Advanced

Dependency blast radius

A lockfile lists what you install, not why. Load the resolved dependency graphs of three packages, and a handful of queries answer the questions a flat list cannot: which known advisories each package can reach and along which chain, what else goes away if you drop a dependency, and whether a build should fail.

The data is licensed CC BY 4.0; see About the data.

The data is what deps.dev returned on 2026-09-24 for npm/[email protected], npm/[email protected], and pypi/[email protected]: each package's resolved dependency graph, the advisory ids deps.dev lists for every version in it, and each advisory's title and CVSS v3 score. deps.dev, run by Google, publishes the data it generates, such as these resolved graphs, under CC BY 4.0. The advisories come from the GitHub and PyPA advisory databases through OSV.dev, also CC BY 4.0.

The graph is (:Version {key, name, version})-[:DEPENDS_ON {requirement}]->(:Version) for dependencies and (:Version)-[:AFFECTED_BY]->(:Advisory {id, title, cvss3}) for advisories. The three packages you start from also carry :Root.

Data model: package versions, what they depend on, and the advisories that affect themA root package version (Version:Root) DEPENDS_ON other Version nodes, which depend on further versions in turn; each DEPENDS_ON relationship records its version requirement. Any Version, the root included, can be AFFECTED_BY an Advisory, which has an id, a title and a CVSS 3 score.Version:Rootkey · systemname · versionVersionkey · systemnameversionAdvisoryid · titlecvss3DEPENDS_ONrequirementDEPENDS_ONrequirementAFFECTED_BYAFFECTED_BY
Data model: package versions, what they depend on, and the advisories that affect themA root package version (Version:Root) DEPENDS_ON other Version nodes, which depend on further versions in turn; each DEPENDS_ON relationship records its version requirement. Any Version, the root included, can be AFFECTED_BY an Advisory, which has an id, a title and a CVSS 3 score.Version:Rootkey · systemname · versionVersionkey · systemnameversionAdvisoryid · titlecvss3DEPENDS_ONrequirementDEPENDS_ONrequirementAFFECTED_BYAFFECTED_BY
Data model: package versions, what they depend on, and the advisories that affect them

Load the dependency graphs

A version key such as NPM/[email protected] names one node, so MERGE on it never creates a duplicate. The queries below look versions up by package name, so name gets an index.

Step 1
Show the setup step
CREATE CONSTRAINT version_key IF NOT EXISTS FOR (v:Version) REQUIRE v.key IS UNIQUE;
CREATE CONSTRAINT advisory_id IF NOT EXISTS FOR (a:Advisory) REQUIRE a.id IS UNIQUE;
CREATE INDEX version_name IF NOT EXISTS FOR (v:Version) ON (v.name)
Recorded result
(no rows) · indexes added: 1, constraints added: 2

The fixture keeps the shape of deps.dev's response: each graph is a nodes list plus an edges list whose fromNode and toNode are positions in nodes. deps.dev lists the package itself first, with relation: "SELF". A list comprehension turns a graph's nodes into keys once, so every edge becomes two lookups by position. Versions that two graphs share, such as [email protected], merge into one node. The edge statement projects the two keys with WITH before it matches, which also keeps the large g map out of each of the 148 edge rows.

Step 2
Show the setup step
UNWIND $graphs AS g
UNWIND g.nodes AS n
MERGE (v:Version {key: n.system + '/' + n.name + '@' + n.version})
  ON CREATE SET v.system = n.system, v.name = n.name, v.version = n.version
WITH v, n WHERE n.relation = 'SELF'
SET v:Root;

UNWIND $graphs AS g
WITH g, [n IN g.nodes | n.system + '/' + n.name + '@' + n.version] AS keys
UNWIND g.edges AS e
WITH keys[e.fromNode] AS from, keys[e.toNode] AS to, e.requirement AS requirement
MATCH (a:Version {key: from}), (b:Version {key: to})
MERGE (a)-[d:DEPENDS_ON]->(b)
  ON CREATE SET d.requirement = requirement;

UNWIND $advisories AS r
CREATE (:Advisory {id: r.id, title: r.title, cvss3: r.cvss3});

UNWIND $advisoryKeys AS r
MATCH (v:Version {key: r.version})
UNWIND r.advisories AS id
MATCH (a:Advisory {id: id})
CREATE (v)-[:AFFECTED_BY]->(a);

MATCH (v:Version) WITH count(v) AS versions
MATCH (r:Root) WITH versions, count(r) AS roots
MATCH ()-[d:DEPENDS_ON]->() WITH versions, roots, count(d) AS dependsOn
MATCH (a:Advisory)
RETURN versions, roots, dependsOn, count(a) AS advisories,
       COUNT { ()-[:AFFECTED_BY]->() } AS affectedBy
Loads deps.json (23 KB)
Recorded result
versionsrootsdependsOnadvisoriesaffectedBy
8931483838

1 row · nodes created: 127, relationships created: 186, properties set: 618, labels added: 130

What each package pulls in

Every variable-length pattern in this recipe is bounded at 12 hops. The longest chain in the data is 11, so {1,12} reaches everything, and a bound keeps a query from running away on a bigger graph.

Step 3
MATCH (r:Root)
WITH r, COUNT { (r)-[:DEPENDS_ON]->() } AS direct
MATCH p = (r)-[:DEPENDS_ON]->{1,12}(v:Version)
RETURN r.name AS root, direct,
       count(DISTINCT v) AS transitive,
       count(p) AS chains,
       max(length(p)) AS longest
ORDER BY root
Recorded result
rootdirecttransitivechainslongest
"express"317049911
"jsonwebtoken"1014164
"requests"4441

3 rows

express names 31 dependencies and ends up with 70 versions, reached along 499 distinct chains. The same version is usually reachable in many ways, which is why "what does dropping X remove?" needs a graph query further down.

Which advisories can each package reach?

SHORTEST 1 keeps one shortest chain for each pair of end nodes. Only the start is pinned here, so one search per root finds the shortest chain to every version it reaches. Matching (r:Root), (v:Version) first and pinning both ends would run a separate search for each of the 33 pairs instead. The lower bound is 0, so a root's own advisories count too, at depth 0. EXISTS { } keeps only versions that have an advisory, and the list comprehension turns the path into package names.

Step 4
MATCH p = SHORTEST 1 (r:Root)-[:DEPENDS_ON]->{0,12}(v:Version)
WHERE EXISTS { (v)-[:AFFECTED_BY]->() }
MATCH (v)-[:AFFECTED_BY]->(a:Advisory)
RETURN r.name AS root, v.key AS vulnerable, length(p) AS depth,
       count(a) AS advisories, max(a.cvss3) AS maxCvss,
       [n IN nodes(p) | n.name] AS path
ORDER BY root, depth, vulnerable
Recorded result
rootvulnerabledepthadvisoriesmaxCvsspath
"express""NPM/[email protected]"026.1["express"]
"express""NPM/[email protected]"127.5["express", "body-parser"]
"express""NPM/[email protected]"110.0["express", "cookie"]
"express""NPM/[email protected]"137.5["express", "path-to-regexp"]
"express""NPM/[email protected]"135.3["express", "qs"]
"express""NPM/[email protected]"115.0["express", "send"]
"express""NPM/[email protected]"115.0["express", "serve-static"]
"jsonwebtoken""NPM/[email protected]"038.1["jsonwebtoken"]
"requests""PYPI/[email protected]"086.1["requests"]
"requests""PYPI/[email protected]"147.5["requests", "idna"]
"requests""PYPI/[email protected]"1107.5["requests", "urllib3"]

11 rows

A version a root cannot reach produces no row, so express never lists urllib3.

Note deps.dev reports a cvss3 of 0 for an advisory without a CVSS v3 score, such as the cookie one. Read 0.0 as "unscored", not "harmless".

See the chains

The same question for one root, as paths that end at the advisory. Open the graph view. Edit root to jsonwebtoken or requests, or raise minCvss to keep only the severe ones:

Step 5
MATCH p = SHORTEST 1 (r:Root {name: $root})-[:DEPENDS_ON]->{0,12}(:Version)
                     -[:AFFECTED_BY]->(a:Advisory)
WHERE a.cvss3 >= $minCvss
RETURN a.id AS advisory, a.cvss3 AS cvss, p
ORDER BY cvss DESC, advisory
Parameters
Recorded result
// 13 rows
[{advisory: "GHSA-37ch-88jc-xwx2", cvss: 7.5} | (n0:Version:Root {key: "NPM/[email protected]", name: "express", system: "NPM", version: "4.18.2"})-[r19:DEPENDS_ON {requirement: "0.1.7"}]->(n51:Version {key: "NPM/[email protected]", name: "path-to-regexp", system: "NPM", version: "0.1.7"})-[r156:AFFECTED_BY]->(n90:Advisory {cvss3: 7.5, id: "GHSA-37ch-88jc-xwx2", title: "path-to-regexp vulnerable to Regular Expression Denial of Service via multiple route parameters"})]
[{advisory: "GHSA-9wv6-86v2-598j", cvss: 7.5} | (n0)-[r19]->(n51)-[r157:AFFECTED_BY]->(n97:Advisory {cvss3: 7.5, id: "GHSA-9wv6-86v2-598j", title: "path-to-regexp outputs backtracking regular expressions"})]
[{advisory: "GHSA-qwcr-r2fm-qrc7", cvss: 7.5} | (n0)-[r2:DEPENDS_ON {requirement: "1.20.1"}]->(n5:Version {key: "NPM/[email protected]", name: "body-parser", system: "NPM", version: "1.20.1"})-[r148:AFFECTED_BY]->(n110:Advisory {cvss3: 7.5, id: "GHSA-qwcr-r2fm-qrc7", title: "body-parser vulnerable to denial of service when url encoding is enabled"})]
[{advisory: "GHSA-rhx6-c78j-4q9w", cvss: 7.5} | (n0)-[r19]->(n51)-[r158:AFFECTED_BY]->(n112:Advisory {cvss3: 7.5, id: "GHSA-rhx6-c78j-4q9w", title: "path-to-regexp contains a ReDoS"})]
[{advisory: "GHSA-rv95-896h-c2vc", cvss: 6.1} | (n0)-[r152:AFFECTED_BY]->(n113:Advisory {cvss3: 6.1, id: "GHSA-rv95-896h-c2vc", title: "Express.js Open Redirect in malformed URLs"})]
[{advisory: "GHSA-4mjr-xmp4-gh2g", cvss: 5.3} | (n0)-[r21:DEPENDS_ON {requirement: "6.11.0"}]->(n53:Version {key: "NPM/[email protected]", name: "qs", system: "NPM", version: "6.11.0"})-[r159:AFFECTED_BY]->(n92:Advisory {cvss3: 5.3, id: "GHSA-4mjr-xmp4-gh2g", title: "qs: Denial of Service via Attacker Controlled isBuffer"})]
[{advisory: "GHSA-cm22-4g7w-348p", cvss: 5.0} | (n0)-[r25:DEPENDS_ON {requirement: "1.15.0"}]->(n59:Version {key: "NPM/[email protected]", name: "serve-static", system: "NPM", version: "1.15.0"})-[r163:AFFECTED_BY]->(n99:Advisory {cvss3: 5.0, id: "GHSA-cm22-4g7w-348p", title: "serve-static vulnerable to template injection that can lead to XSS"})]
[{advisory: "GHSA-m6fv-jmcg-4jfg", cvss: 5.0} | (n0)-[r24:DEPENDS_ON {requirement: "0.18.0"}]->(n58:Version {key: "NPM/[email protected]", name: "send", system: "NPM", version: "0.18.0"})-[r162:AFFECTED_BY]->(n105:Advisory {cvss3: 5.0, id: "GHSA-m6fv-jmcg-4jfg", title: "send vulnerable to template injection that can lead to XSS"})]
[{advisory: "GHSA-qw6h-vgh9-j6wx", cvss: 5.0} | (n0)-[r151:AFFECTED_BY]->(n109:Advisory {cvss3: 5.0, id: "GHSA-qw6h-vgh9-j6wx", title: "express vulnerable to XSS via response.redirect()"})]
[{advisory: "GHSA-6rw7-vpxm-498p", cvss: 3.7} | (n0)-[r21]->(n53)-[r160:AFFECTED_BY]->(n94:Advisory {cvss3: 3.7, id: "GHSA-6rw7-vpxm-498p", title: "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion"})]
[{advisory: "GHSA-v422-hmwv-36x6", cvss: 3.7} | (n0)-[r2]->(n5)-[r149:AFFECTED_BY]->(n114:Advisory {cvss3: 3.7, id: "GHSA-v422-hmwv-36x6", title: "body-parser vulnerable to denial of service when invalid limit value silently disables size enforcement"})]
[{advisory: "GHSA-w7fw-mjwx-w883", cvss: 3.7} | (n0)-[r21]->(n53)-[r161:AFFECTED_BY]->(n115:Advisory {cvss3: 3.7, id: "GHSA-w7fw-mjwx-w883", title: "qs's arrayLimit bypass in comma parsing allows denial of service"})]
[{advisory: "GHSA-pxg6-pf52-xh8x", cvss: 0.0} | (n0)-[r5:DEPENDS_ON {requirement: "0.5.0"}]->(n11:Version {key: "NPM/[email protected]", name: "cookie", system: "NPM", version: "0.5.0"})-[r150:AFFECTED_BY]->(n107:Advisory {cvss3: 0.0, id: "GHSA-pxg6-pf52-xh8x", title: "cookie accepts cookie name, path, and domain with out of bounds characters"})]

What needs a package?

Every chain from a root to any version of the package named $name. qs is required twice: by express directly and by body-parser. Edit name to debug to see five chains.

Step 6
MATCH p = (r:Root)-[:DEPENDS_ON]->{1,12}(:Version {name: $name})
RETURN r.name AS root, length(p) AS hops, [n IN nodes(p) | n.name] AS chain
ORDER BY root, hops, chain
LIMIT 25
Parameters
Recorded result
roothopschain
"express"1["express", "qs"]
"express"2["express", "body-parser", "qs"]

2 rows

What disappears if a package is dropped?

Compare everything a root reaches with everything it still reaches along chains that never step onto the dropped package. The second set is a quantified path pattern with a predicate inside it:

(r)((x)-[:DEPENDS_ON]->(y) WHERE y.name <> $drop){1,12}(k:Version)

The WHERE applies to every repetition, so a chain stops before it enters $drop. What the root reaches but can no longer keep is the blast radius. EXISTS { } skips roots that never reach the package, and COUNT { } counts the advisories that leave with each version.

Step 7
MATCH (r:Root)
WHERE EXISTS { (r)-[:DEPENDS_ON]->{1,12}(:Version {name: $drop}) }
MATCH (r)-[:DEPENDS_ON]->{1,12}(v:Version)
WITH r, collect(DISTINCT v) AS reachable
OPTIONAL MATCH (r)((x)-[:DEPENDS_ON]->(y) WHERE y.name <> $drop){1,12}(k:Version)
WITH r, reachable, collect(DISTINCT k) AS kept
UNWIND [v IN reachable WHERE NOT v IN kept] AS gone
RETURN r.name AS root, gone.key AS removed,
       COUNT { (gone)-[:AFFECTED_BY]->() } AS advisories
ORDER BY root, removed
Parameters
Recorded result
rootremovedadvisories
"express""NPM/[email protected]"2
"express""NPM/[email protected]"0
"express""NPM/[email protected]"0
"express""NPM/[email protected]"0
"express""NPM/[email protected]"0

5 rows

Dropping body-parser also removes the four packages only it needs, and two advisories. It does not remove qs: the previous step showed that express requires qs directly. Try debug: it takes [email protected] along, but [email protected] stays because send needs it.

Note Name the collected lists anything but all. Relate currently accepts WITH collect(v) AS all, but reads a later bare all as a keyword: size(all) returns null instead of a count. Backquote it (`all`) if you must use the name.

Which dependency has the largest blast radius?

The same comparison for the direct dependencies of one root, all at once. Each CALL subquery runs once per incoming row: the first collects what the root reaches, the second what it keeps without d. A direct dependency with no dependencies of its own takes nothing else along, so EXISTS { } skips those, and each remaining one costs a full walk of the root's chains. A pattern comprehension counts the advisories that would leave too.

Step 8
MATCH (r:Root {name: $root})
CALL (r) {
  MATCH (r)-[:DEPENDS_ON]->{1,12}(v:Version)
  RETURN collect(DISTINCT v) AS reachable
}
MATCH (r)-[:DEPENDS_ON]->(d:Version)
WHERE EXISTS { (d)-[:DEPENDS_ON]->() }
CALL (r, d) {
  MATCH (r)((x)-[:DEPENDS_ON]->(y) WHERE y.name <> d.name){1,12}(k:Version)
  RETURN collect(DISTINCT k) AS kept
}
WITH d.name AS cut, [v IN reachable WHERE NOT v IN kept] AS gone
RETURN cut, size(gone) AS removed,
       size([(v:Version)-[:AFFECTED_BY]->(a) WHERE v IN gone | a.id]) AS advisories,
       [v IN gone | v.name + '@' + v.version] AS versions
ORDER BY removed DESC, cut
LIMIT 10
Parameters
Recorded result

qs pulls in 22 of express's 70 versions, most of them small helpers. send and debug each take a different version of ms with them, because express resolves ms twice. The predicate compares package names, so dropping a package drops every version of it. Try jsonwebtoken.

Note The versions lists are recorded in BFS discovery order: the quantified paths here are served by the frontier-NFA kernel (ADR-0478, Decision 4), which discovers nodes breadth-first, so collect() over a frontier-served traversal lists them in that order instead of the previous DFS enumeration order. Both are valid — Cypher leaves the order inside collected lists undefined — and the row sets, counts, and members are unchanged.

Note The variable is cut because DROP is a keyword: WITH d.name AS drop is a syntax error in Relate. The parameter $drop is fine.

Hot spots

The versions with the most direct dependents. A change to one of these touches many packages at once:

Step 9
MATCH (v:Version)
RETURN v.key AS version, COUNT { (:Version)-[:DEPENDS_ON]->(v) } AS dependents
ORDER BY dependents DESC, version
LIMIT 8
Recorded result

The deepest versions, measured by their shortest distance from the root. The longest chain was 11 hops, but no version is more than 6 hops from its root by the shortest route:

Step 10
MATCH p = SHORTEST 1 (r:Root)-[:DEPENDS_ON]->{1,12}(v:Version)
RETURN r.key AS root, v.key AS version, length(p) AS depth
ORDER BY depth DESC, root, version
LIMIT 8
Recorded result

And the versions that more than one root resolves to, where one upgrade lands in two places:

Step 11
MATCH (r:Root)-[:DEPENDS_ON]->{1,12}(v:Version)
WITH v, collect(DISTINCT r.name) AS roots
WHERE size(roots) > 1
RETURN v.key AS version, roots
ORDER BY version
Recorded result
versionroots
"NPM/[email protected]"["express", "jsonwebtoken"]
"NPM/[email protected]"["express", "jsonwebtoken"]

2 rows

Gate CI on reachable advisories

The reachability query becomes a build check: list every advisory at or above $minCvss that some root can reach, minus the ids in $allow that a reviewer accepted. No rows means the build passes.

Step 12
MATCH p = SHORTEST 1 (r:Root)-[:DEPENDS_ON]->{0,12}(v:Version)
MATCH (v)-[:AFFECTED_BY]->(a:Advisory)
WHERE a.cvss3 >= $minCvss AND NOT a.id IN $allow
RETURN a.id AS advisory, a.cvss3 AS cvss, v.key AS version,
       [n IN nodes(p) | n.name] AS path
ORDER BY cvss DESC, advisory, version
Parameters
Recorded result
advisorycvssversionpath
"GHSA-8cf7-32gw-wr33"8.1"NPM/[email protected]"["jsonwebtoken"]
"GHSA-37ch-88jc-xwx2"7.5"NPM/[email protected]"["express", "path-to-regexp"]
"GHSA-38jv-5279-wg99"7.5"PYPI/[email protected]"["requests", "urllib3"]
"GHSA-9wv6-86v2-598j"7.5"NPM/[email protected]"["express", "path-to-regexp"]
"GHSA-qwcr-r2fm-qrc7"7.5"NPM/[email protected]"["express", "body-parser"]
"GHSA-rhx6-c78j-4q9w"7.5"NPM/[email protected]"["express", "path-to-regexp"]
"PYSEC-2024-60"7.5"PYPI/[email protected]"["requests", "idna"]
"PYSEC-2026-1996"7.5"PYPI/[email protected]"["requests", "urllib3"]

8 rows

Eight findings fail the build. Add reviewed ids to allow, or change minCvss, and run it again. In CI, save the query as gate.cypher, run it read-only against a database built with the load steps above, and let jq -e turn a non-empty result into exit code 1:

relate query --database ./deps.rl --read-only --mode json --max-rows 0 \
  --param minCvss=7.5 --param 'allow=["GHSA-qwcr-r2fm-qrc7"]' \
  < gate.cypher > findings.json
jq -e 'length == 0' findings.json

Use relate in CI builds the same gate around a store checked out with the code.

About the data

Dataset
Resolved dependency graphs of three packages with their advisories; 89 versions, 148 dependency edges, 38 advisories (23 KB)
Source
deps.dev API v3 responses for npm/[email protected], npm/[email protected] and pypi/[email protected], cached on 2026-09-24 by the examples project's 05-deps-dev/fetch.py
License
CC BY 4.0

Every data block is published beside this page as data/<name>.json, and the whole recipe as markdown.

All recipes