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.
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.
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)(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.
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| versions | roots | dependsOn | advisories | affectedBy |
|---|---|---|---|---|
| 89 | 3 | 148 | 38 | 38 |
1 row · nodes created: 127, relationships created: 186, properties set: 618, labels added: 130
| versions | roots | dependsOn | advisories | affectedBy | |----------|-------|-----------|------------|------------| | 89 | 3 | 148 | 38 | 38 | 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.
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| root | direct | transitive | chains | longest |
|---|---|---|---|---|
| "express" | 31 | 70 | 499 | 11 |
| "jsonwebtoken" | 10 | 14 | 16 | 4 |
| "requests" | 4 | 4 | 4 | 1 |
3 rows
| root | direct | transitive | chains | longest | |----------------|--------|------------|--------|---------| | "express" | 31 | 70 | 499 | 11 | | "jsonwebtoken" | 10 | 14 | 16 | 4 | | "requests" | 4 | 4 | 4 | 1 | 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.
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| root | vulnerable | depth | advisories | maxCvss | path |
|---|---|---|---|---|---|
| "express" | "NPM/[email protected]" | 0 | 2 | 6.1 | ["express"] |
| "express" | "NPM/[email protected]" | 1 | 2 | 7.5 | ["express", "body-parser"] |
| "express" | "NPM/[email protected]" | 1 | 1 | 0.0 | ["express", "cookie"] |
| "express" | "NPM/[email protected]" | 1 | 3 | 7.5 | ["express", "path-to-regexp"] |
| "express" | "NPM/[email protected]" | 1 | 3 | 5.3 | ["express", "qs"] |
| "express" | "NPM/[email protected]" | 1 | 1 | 5.0 | ["express", "send"] |
| "express" | "NPM/[email protected]" | 1 | 1 | 5.0 | ["express", "serve-static"] |
| "jsonwebtoken" | "NPM/[email protected]" | 0 | 3 | 8.1 | ["jsonwebtoken"] |
| "requests" | "PYPI/[email protected]" | 0 | 8 | 6.1 | ["requests"] |
| "requests" | "PYPI/[email protected]" | 1 | 4 | 7.5 | ["requests", "idna"] |
| "requests" | "PYPI/[email protected]" | 1 | 10 | 7.5 | ["requests", "urllib3"] |
11 rows
| root | vulnerable | depth | advisories | maxCvss | path | |----------------|----------------------------|-------|------------|---------|-------------------------------| | "express" | "NPM/[email protected]" | 0 | 2 | 6.1 | ["express"] | | "express" | "NPM/[email protected]" | 1 | 2 | 7.5 | ["express", "body-parser"] | | "express" | "NPM/[email protected]" | 1 | 1 | 0.0 | ["express", "cookie"] | | "express" | "NPM/[email protected]" | 1 | 3 | 7.5 | ["express", "path-to-regexp"] | | "express" | "NPM/[email protected]" | 1 | 3 | 5.3 | ["express", "qs"] | | "express" | "NPM/[email protected]" | 1 | 1 | 5.0 | ["express", "send"] | | "express" | "NPM/[email protected]" | 1 | 1 | 5.0 | ["express", "serve-static"] | | "jsonwebtoken" | "NPM/[email protected]" | 0 | 3 | 8.1 | ["jsonwebtoken"] | | "requests" | "PYPI/[email protected]" | 0 | 8 | 6.1 | ["requests"] | | "requests" | "PYPI/[email protected]" | 1 | 4 | 7.5 | ["requests", "idna"] | | "requests" | "PYPI/[email protected]" | 1 | 10 | 7.5 | ["requests", "urllib3"] | 11 rows
A version a root cannot reach produces no row, so express never lists
urllib3.
Note deps.dev reports a
cvss3of 0 for an advisory without a CVSS v3 score, such as thecookieone. Read0.0as "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:
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, advisoryParameters
// 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.
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 25Parameters
| root | hops | chain |
|---|---|---|
| "express" | 1 | ["express", "qs"] |
| "express" | 2 | ["express", "body-parser", "qs"] |
2 rows
| root | hops | chain | |-----------|------|----------------------------------| | "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.
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, removedParameters
| root | removed | advisories |
|---|---|---|
| "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
| root | removed | advisories | |-----------|--------------------------|------------| | "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 acceptsWITH collect(v) AS all, but reads a later bareallas 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.
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 10Parameters
10 rows
| cut | removed | advisories | versions | |-----------------------|---------|------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | "qs" | 22 | 3 | ["[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]"] | | "body-parser" | 5 | 2 | ["[email protected]", "[email protected]", "[email protected]", "[email protected]", "[email protected]"] | | "http-errors" | 3 | 0 | ["[email protected]", "[email protected]", "[email protected]"] | | "proxy-addr" | 3 | 0 | ["[email protected]", "[email protected]", "[email protected]"] | | "send" | 3 | 1 | ["[email protected]", "[email protected]", "[email protected]"] | | "accepts" | 2 | 0 | ["[email protected]", "[email protected]"] | | "debug" | 2 | 0 | ["[email protected]", "[email protected]"] | | "on-finished" | 2 | 0 | ["[email protected]", "[email protected]"] | | "type-is" | 2 | 0 | ["[email protected]", "[email protected]"] | | "content-disposition" | 1 | 0 | ["[email protected]"] | 10 rows
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
versionslists 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, socollect()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
cutbecauseDROPis a keyword:WITH d.name AS dropis a syntax error in Relate. The parameter$dropis fine.
Hot spots
The versions with the most direct dependents. A change to one of these touches many packages at once:
MATCH (v:Version)
RETURN v.key AS version, COUNT { (:Version)-[:DEPENDS_ON]->(v) } AS dependents
ORDER BY dependents DESC, version
LIMIT 8| version | dependents |
|---|---|
| "NPM/[email protected]" | 8 |
| "NPM/[email protected]" | 5 |
| "NPM/[email protected]" | 4 |
| "NPM/[email protected]" | 4 |
| "NPM/[email protected]" | 4 |
| "NPM/[email protected]" | 4 |
| "NPM/[email protected]" | 4 |
| "NPM/[email protected]" | 4 |
8 rows
| version | dependents | |-----------------------------|------------| | "NPM/[email protected]" | 8 | | "NPM/[email protected]" | 5 | | "NPM/[email protected]" | 4 | | "NPM/[email protected]" | 4 | | "NPM/[email protected]" | 4 | | "NPM/[email protected]" | 4 | | "NPM/[email protected]" | 4 | | "NPM/[email protected]" | 4 | 8 rows
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:
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| root | version | depth |
|---|---|---|
| "NPM/[email protected]" | "NPM/[email protected]" | 6 |
| "NPM/[email protected]" | "NPM/[email protected]" | 5 |
| "NPM/[email protected]" | "NPM/[email protected]" | 5 |
| "NPM/[email protected]" | "NPM/[email protected]" | 5 |
| "NPM/[email protected]" | "NPM/[email protected]" | 5 |
| "NPM/[email protected]" | "NPM/[email protected]" | 5 |
| "NPM/[email protected]" | "NPM/[email protected]" | 5 |
| "NPM/[email protected]" | "NPM/[email protected]" | 5 |
8 rows
| root | version | depth | |----------------------|--------------------------------------|-------| | "NPM/[email protected]" | "NPM/[email protected]" | 6 | | "NPM/[email protected]" | "NPM/[email protected]" | 5 | | "NPM/[email protected]" | "NPM/[email protected]" | 5 | | "NPM/[email protected]" | "NPM/[email protected]" | 5 | | "NPM/[email protected]" | "NPM/[email protected]" | 5 | | "NPM/[email protected]" | "NPM/[email protected]" | 5 | | "NPM/[email protected]" | "NPM/[email protected]" | 5 | | "NPM/[email protected]" | "NPM/[email protected]" | 5 | 8 rows
And the versions that more than one root resolves to, where one upgrade lands in two places:
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| version | roots |
|---|---|
| "NPM/[email protected]" | ["express", "jsonwebtoken"] |
| "NPM/[email protected]" | ["express", "jsonwebtoken"] |
2 rows
| version | roots | |-------------------------|-----------------------------| | "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.
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, versionParameters
| advisory | cvss | version | path |
|---|---|---|---|
| "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
| advisory | cvss | version | path | |-----------------------|------|----------------------------|-------------------------------| | "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.jsonUse 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.