Skip to content

GitHub Dependency Graph

Lesson 5 of 8Intermediate12 min readGit Security & DevSecOps · Code & Dependency SecurityVerified: GitHub dependency graph and dependency submission API documentation, September 2026

The dependency graph is the least visible feature in this cluster and the one everything else depends on. Dependabot alerts join it against the advisory database. Dependency review compares two versions of it. Your SBOM export is generated from it.

If it is wrong, everything built on it is wrong in the same direction — quietly, and towards under-reporting.

What it is. An inventory of the packages a repository depends on, direct and transitive, built by parsing manifest and lock files, plus anything submitted through the dependency submission API.

How it is built. GitHub parses recognised files — package-lock.json, requirements.txt, go.sum, pom.xml, Cargo.lock and their equivalents — whenever they change.

Why it matters. You cannot assess exposure to an advisory without knowing whether you depend on the affected package, at what version, and through what path.

Its central limitation. It sees what is declared in files it recognises. Anything else — vendored code, system packages installed in a container, dynamically fetched dependencies — is invisible unless you submit it.

The distinction between the two determines the quality of the entire inventory, and it is worth being precise about.

A manifest declares intent. package.json says “any 4.x version of this library”. It names your direct dependencies and the ranges you will accept.

A lock file records fact. package-lock.json says “version 4.18.2, with this integrity hash, and these 340 transitive dependencies at these exact versions”.

The consequence:

PresentThe graph knows
Manifest onlyYour direct dependencies, approximately — a range, not a version
Manifest and lock fileEvery resolved version, direct and transitive, exactly

A repository without a lock file has a graph that reports what you asked for rather than what you got. Since an advisory concerns specific versions, “we depend on some 4.x” cannot be joined against “versions 4.17.0 to 4.17.20 are vulnerable” with any confidence.

The graph distinguishes dependencies you declared from ones that arrived because something else needed them, and the distinction shapes remediation.

Direct dependencies you chose and can change.

Transitive dependencies arrived through the tree. In a typical application they outnumber direct ones by an order of magnitude, and they are where most advisories land — because most of your dependency tree is code somebody else’s dependency chose.

When an alert names a transitive dependency, the options are:

  • Upgrade the direct dependency that brought it in, if a newer version resolves a fixed transitive version. Cleanest.
  • Override the resolved version through your package manager’s override mechanism. Effective, and it puts you on a combination the maintainer never tested.
  • Wait for the direct dependency to update. Sometimes the only option, and the reason “how responsive is this maintainer?” is a security question.

The transitive tree is also the argument for the graph existing at all. Nobody can answer “do we depend on this package?” by reading manifests, because the answer is usually four levels down.

This is the operationally important part, because the failure mode is silence.

Vendored code. Dependencies copied into the repository rather than declared. Nothing parses them, so nothing knows they are there — and they never update either.

System packages in containers. apt-get install in a Dockerfile installs packages the graph has no view of. This is a large category: for many applications, most of the CVEs are in the base image rather than in the application’s own dependencies.

Build-time downloads. Anything fetched by a script during the build — a binary pulled from a release page, a model file, a data set. Present in the artefact, absent from every manifest.

Dynamically resolved plugins. Loaded at runtime by name or path.

Ecosystems with no lock file convention, where only the manifest exists to parse.

Monorepo layouts the parser does not expect. A workspace with manifests in unconventional locations, or a build that assembles a manifest at run time, can be parsed partially or not at all — and the graph reports what it found without indicating what it missed.

Anything not in a recognised file format. The parser knows specific filenames in specific ecosystems. A dependency declared in a bespoke configuration file, however carefully, is not a dependency the graph can see.

Together these mean the graph is a lower bound on your dependencies, not a complete inventory. How much lower depends entirely on your build.

The mechanism for closing those gaps. It lets a build process report the dependencies it actually resolved, which is authoritative in a way that file parsing is not.

The pattern: during a build, ask the build tool what it resolved, and submit that.

- name: Submit the resolved dependency tree
uses: advanced-security/maven-dependency-submission-action@v5

Similar actions exist for other ecosystems, and the underlying REST endpoint accepts a submission in a documented format so you can produce one from any build system.

Two properties make this worth the setup for compiled and multi-stage builds:

It reports what was resolved, not what was declared. For ecosystems where resolution is complex — Maven, Gradle, anything with profiles or conditional dependencies — this is the difference between a guess and a fact.

It reaches things files cannot describe. A build that generates code, fetches artefacts, or assembles a container can report all of it.

Submitted dependencies participate fully: Dependabot alerts on them, dependency review sees them, and they appear in the SBOM export.

Threat. A vulnerability exists in something you depend on, and you do not know you depend on it — so you neither assess it nor fix it.

Attack surface. The whole transitive tree, plus everything the graph cannot see: vendored code, base image packages, build-time downloads, dynamically loaded plugins.

Impact. Not the vulnerability itself, but the response failure. When a widely-exploited advisory lands, the first question every organisation asks is “are we affected?” A repository with a complete inventory answers in minutes. One without spends days on a manual audit, during which the answer is “we do not know”, and “we do not know” is operationally identical to “yes” for anything urgent.

Control. A complete, automatically maintained inventory: committed lock files, dependency submission for what parsing cannot see, and container scanning for the layer below.

Verification. Pick a package deep in your tree and ask the graph whether you depend on it, at what version, and through which path. Then verify that answer against your build. If they disagree, the inventory is describing something other than what you ship.

The graph is queryable, which makes “are we affected?” a scriptable question rather than a survey.

The export is a two-step asynchronous flow: ask for a report, then poll for it. Wrapping that in a function keeps the interesting part readable:

Terminal window
{/* Fetch one repository's SBOM, polling until the report is ready */}
repo_sbom() {
local repo="$1"
local url
url=$(gh api "/repos/${repo}/dependency-graph/sbom/generate-report" --jq '.url' 2>/dev/null) || return 1
for _ in $(seq 1 30); do
if gh api "${url}" 2>/dev/null; then return 0; fi
sleep 2
done
return 1
}
Terminal window
{/* Every component in one repository, with its version */}
repo_sbom OWNER/REPO | jq -r '.sbom.packages[] | "\(.name) \(.versionInfo // "unknown")"'

Across an organisation, the same call in a loop produces the inventory nobody has:

Terminal window
{/* Which repositories depend on a given package name */}
for name in $(gh repo list YOUR_ORG --limit 200 --json nameWithOwner --jq '.[].nameWithOwner'); do
if repo_sbom "${name}" | jq -r '.sbom.packages[].name' | grep -qx "PACKAGE_NAME"; then
echo "${name}"
fi
done

That loop is worth writing before you need it. During an incident, the difference between having it and not is hours, and the hours are at the front of the response where they matter most.

Two caveats. The endpoint reports what the graph knows, so every limitation above applies to the answer. And a negative result means “not in the graph”, not “not present” — which is exactly why the coverage check in the next section matters.

The graph can be exported as an SBOM in SPDX format, from the repository’s interface or through the API:

Terminal window
{/* Start the export; returns a URL containing an SBOM id */}
gh api /repos/OWNER/REPO/dependency-graph/sbom/generate-report
{/* Poll until ready 201 while computing, then a redirect to the document */}
gh api /repos/OWNER/REPO/dependency-graph/sbom/fetch-report/SBOM_UUID > sbom.spdx.json

This is useful and it inherits every limitation above. An SBOM generated from the dependency graph describes what the graph knows, which is what was parsed or submitted — not necessarily what is in your artefact.

For an SBOM describing a built artefact, generate it from the artefact at build time. See SBOMs for the distinction, which matters more than it sounds: an SBOM of your repository and an SBOM of your container image are different documents describing different things, and consumers usually want the second.

The graph reports presence. Presence is not exposure, and conflating them produces both of the classic triage errors.

Three questions separate them, and only the first is answerable from the inventory.

Is it in the tree? The graph answers this. A no here is genuinely a no, subject to coverage.

Is the vulnerable code path reachable from our usage? A library with twenty modules where you import one, and the advisory concerns a different one, is a dependency you have and an exposure you do not. Nothing automated answers this — it requires reading the advisory and your code.

Does our deployment expose it? A vulnerability requiring an attacker-supplied file path is a different risk in a service that accepts uploads and in a batch job that reads one internal directory.

The practical consequence is that the inventory is the input to triage, not the output. A team that treats every graph hit as an incident burns out; a team that treats presence as harmless until proven otherwise misses the one that matters.

The inventory’s job is to make the first question instant, so that the effort goes into the second and third. Vulnerability alerts is about doing that well.

The question worth answering is not “is the dependency graph enabled?” but “does it describe what we actually ship?”

  1. Count what the graph reports. The repository’s dependency graph view gives a total.

  2. Count what your build resolves. npm ls --all, pip freeze, go list -m all, mvn dependency:list — whatever your ecosystem provides.

  3. Compare. A large gap means files are not being parsed, a lock file is missing, or resolution is doing something the parser cannot see.

  4. Check your container. If you ship an image, scan it and compare the package count against the graph. The difference is your operating system layer, and it is usually substantial.

  5. Close the gaps with the submission API for build-resolved dependencies, and container scanning for the image.

Step 4 is the one that reframes the exercise for most teams. A repository with fifty application dependencies and a base image with four hundred system packages has a dependency graph covering roughly eleven percent of what it ships.

Dependency insights across an organisation

Section titled “Dependency insights across an organisation”

At organisation scale, the graph’s aggregate view answers questions no individual repository can.

Which repositories use a given package, at which versions. This is the incident question, and the whole reason the inventory exists.

Where a version is inconsistent. Fifteen repositories on one version of an internal library and three on another is either a deliberate migration or a set of repositories nobody is maintaining. Both are worth knowing.

Which repositories have no graph at all. A repository with the dependency graph disabled, or with no recognised manifest, contributes nothing to any of the above — and is invisible in exactly the way that matters.

That third question is the one to run first when adopting this. An organisation’s dependency coverage is not the average quality of its inventories; it is the proportion of repositories that have one at all.

The graph is enabled by default on public repositories. On private repositories it is a setting, and it gates the features above it: no graph means no Dependabot alerts and no dependency review.

Two things worth deciding deliberately rather than inheriting.

Do not disable it to reduce alert noise. The noise is Dependabot’s, and Dependabot has its own controls — grouping, cooldowns, auto-triage rules, severity thresholds. Disabling the graph removes the inventory rather than the noise, and it removes it for dependency review and SBOM export as well.

Enable it through an organisation security configuration rather than repository by repository. Otherwise coverage is a function of who remembered, and new repositories inherit whatever the person who created them did.

Assuming the graph is complete. It parses recognised files. Everything else needs submitting.

No lock file. The graph reports ranges rather than resolved versions, and alerts become approximate.

Ignoring the container. For many applications, most CVEs are in the base image, which the graph does not see at all.

Treating the SBOM export as an artefact SBOM. It describes the repository’s declared dependencies, not the contents of what you built.

Disabling it to reduce noise. The alerts are the point; disabling the graph removes the inventory that every other control reads.

Vendoring dependencies without tracking them. Invisible to the graph, and they never update.

Not submitting for complex builds. Maven and Gradle resolution in particular can differ substantially from what a parser infers from the manifest.

The dependency graph is an inventory assembled by reading files. It is authoritative about what those files declare and silent about everything else — so its completeness is a property of your build, not of the feature.

  • The graph is built by parsing manifests and lock files, plus dependency submission API entries
  • A manifest declares intent; a lock file records the resolved fact, which is what advisories join against
  • Transitive dependencies outnumber direct ones and are where most advisories land
  • Remediating a transitive advisory means upgrading the parent, overriding the version, or waiting
  • Vendored code, container packages, build-time downloads and dynamic plugins are invisible to it
  • The dependency submission API reports what a build resolved, which is authoritative where parsing is not
  • Container packages need separate scanning; the graph never sees them
  • The SBOM export describes the graph, not your built artefact
  • Coverage is verified by comparing the graph’s count against what your build actually resolves

Use a disposable repository with a real dependency manifest.

  1. Enable the dependency graph and look at what it reports. Note the total.

  2. Delete the lock file, commit, and look again. Predict: how does the count change, and what happens to the transitive dependencies?

  3. Restore the lock file. Compare the graph’s count against your package manager’s own full list. Predict: do they match?

  4. Add a Dockerfile installing a few system packages. Predict: do they appear in the graph?

  5. Export the SBOM with the gh api command. Predict: does it include the system packages from step 4?

  6. Vendor a small library by copying its source into the repository. Predict: does the graph notice?

  7. If you use Maven or Gradle, add the submission action and compare the resulting graph against the parsed one.

  8. Delete the repository.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

The repository security templates — secrets management and least-privilege token guides — are in the Professional Toolkit.