DEV Community

Cover image for How to generate an SBOM for an Android app (Gradle dependency locking)
Catalin Shipmark
Catalin Shipmark

Posted on Originally published at shipmark.eu

How to generate an SBOM for an Android app (Gradle dependency locking)

In short

  • An Android app resolves its libraries through Gradle. A version catalog (gradle/libs.versions.toml) lists the versions you request, but Gradle's documentation says catalogs do not enforce them. The versions that actually ship are the resolved ones.
  • Gradle dependency locking records the resolved versions in gradle.lockfile. That file can be turned into a CycloneDX SBOM, and the Gradle documentation says lock files should be checked in to source control.
  • Locking is off by default, and the build script's own plugins have a separate lock file. Switch it on, commit the files, then generate one SBOM per release.

What the CRA asks for

Annex I, Part II, point (1) of Regulation (EU) 2024/2847 requires manufacturers to "identify and document vulnerabilities and components contained in products with digital elements, including by drawing up a software bill of materials in a commonly used and machine-readable format covering at the very least the top-level dependencies of the products". The text names no format. CycloneDX and SPDX are the usual choices, and this guide uses CycloneDX.

For an Android app, "components" are the libraries Gradle pulls into the build: AndroidX, Kotlin libraries, networking, analytics and ad SDKs, and their own dependencies. They come from Maven repositories. Anything you copy into the project by hand, such as an .aar or .jar file, is not covered by a lockfile.

Step 1. Know what a version catalog does and does not do

Most current Android projects keep dependency versions in a Gradle version catalog. By default it is the file gradle/libs.versions.toml, which Gradle imports automatically. It has four sections: [versions], [libraries], [bundles] and [plugins].

[versions]
groovy = "3.0.9"

[libraries]
groovy-core = { group = "org.codehaus.groovy", name = "groovy", version.ref = "groovy" }
Enter fullscreen mode Exit fullscreen mode

That example is from Gradle's documentation. The catalog is a good place to read your direct dependencies, but it is not an SBOM. Gradle's documentation states that "version catalogs declare requested versions but do not enforce them", and that Gradle may still select different versions because of conflicts in the dependency graph. Transitive dependencies are not in the catalog at all. For the versions that really end up in the app you need the resolved graph, and dependency locking is the built-in way to record it.

Step 2. Turn on Gradle dependency locking

According to the Gradle user guide, locking is enabled per dependency configuration. To lock all project configurations, use lockAllConfigurations() in the dependencyLocking block:

// build.gradle.kts
dependencyLocking {
    lockAllConfigurations()
}
Enter fullscreen mode Exit fullscreen mode

Then write the lock state:

./gradlew dependencies --write-locks
Enter fullscreen mode Exit fullscreen mode

The documentation says this creates or updates the lock state for each resolved configuration during that build, and that Gradle does not write it if the build fails. What you get:

File What it covers
gradle.lockfile Located at the root of each project or subproject directory. Each line is group:artifact:version followed by the configurations that include it.
buildscript-gradle.lockfile The build script's own classpath (for example, Gradle plugins). lockAllConfigurations() covers project configurations but not the buildscript ones, so these need activateDependencyLocking() inside buildscript.

Three practical points from the same page:

  • Multi-module apps have several lock files. Each project and subproject has its own, and the page notes that dependencies is typically executed on one project only, the root. For a multi-module Android app, check that every module you ship has a lock file with entries, or use a task that resolves all configurations.
  • Commit the files. The page states that lockfiles should be checked in to source control, and the generated file's header says the same.
  • Pick a lock mode on purpose. In strict mode, Gradle fails if a locked configuration has no lock state. Lenient mode keeps dynamic versions pinned but does not fail on other changes. Strict is the safer choice for a release build.

We did not test locking on Android project variants. Android builds have many configurations (one set per build type and flavor), so run the command on your real project and open the result. Confirm that your release variant's runtime dependencies are in it.

Step 3. Turn the lock file into CycloneDX

Option A: in your browser

Drop gradle.lockfile on the Shipmark free scanner. It builds a CycloneDX 1.6 SBOM locally and checks each version against OSV.dev. The file stays in your browser, and only package names and versions are sent to OSV.dev.

Option B: the CycloneDX Gradle plugin

The CycloneDX Gradle plugin (plugin id org.cyclonedx.bom, version 3.6.1 on the Gradle plugin portal when we read it) is described there as creating "an aggregate of all direct and transitive dependencies of a project" and generating a CycloneDX document from it. Apply it in the plugins block:

plugins {
  id("org.cyclonedx.bom") version "3.6.1"
}
Enter fullscreen mode Exit fullscreen mode

Release notes for version 3.0.0 describe a task cyclonedxDirectBom for a single project, with cyclonedxBom aggregating subprojects, and output under build/reports/cyclonedx-direct/ and build/reports/cyclonedx/. Those notes are not fully consistent about the aggregate task name, and older tutorials show build/reports/bom.json. Read the plugin's current README for the version you install. The release notes do not mention Android, so test it on your app module.

Option C: Syft or another scanner

Check the documentation of the scanner you prefer for Gradle lockfile support and CycloneDX output. We did not verify any other tool for this guide.

Whichever route you use, open the result. Confirm that the library names, exact versions and package URLs (pkg:maven/…) look right and that nothing you ship is missing. The free SBOM checker compares a CycloneDX or SPDX file with the CRA minimum.

For known-vulnerability scanning, Google's OSV-Scanner documentation lists gradle.lockfile and buildscript-gradle.lockfile among the lockfiles it supports under the Java ecosystem. Check the current page before you build a pipeline on it.

Step 4. Close the gaps lockfiles leave

These are our practical suggestions, not CRA text.

  • Add what has no lockfile. A local .aar or .jar, a vendored source folder or a closed-source vendor SDK delivered as a file appears in no lock file. List it by hand, with name, version and supplier.
  • Include native code. If the app bundles C or C++ libraries through the NDK, Gradle will not list them. Add them manually.
  • Generate from the exact commit you upload to Google Play. Name the file after the app and version, for example orderly-4.2.0.cdx.json.
  • Keep it with the technical file. See the technical documentation checklist.
  • Re-check old SBOMs. A release that was clean when you shipped it can be affected by a vulnerability published later. An SBOM per release is how you learn quickly whether you are affected. See vulnerability reporting for app teams.

Step 5. Automate it in CI

The free open-source Shipmark SBOM action reads lockfiles on every build or release, writes one CycloneDX 1.6 SBOM and checks it against OSV.dev:

- uses: actions/checkout@v4
- uses: shipmark-eu/sbom-action@v1
  with:
    output: sbom-${{ github.ref_name }}.cdx.json
Enter fullscreen mode Exit fullscreen mode

Common questions

Does my Android app need an SBOM under the CRA?

If you place the app on the EU market as part of a commercial activity, Annex I, Part II, point (1) applies to you as manufacturer. See whether the CRA applies to mobile apps. The full set of requirements applies from 11 December 2027.

Does a Gradle version catalog pin my dependency versions?

No. Gradle's documentation says catalogs declare requested versions but do not enforce them. Dependency locking records the resolved ones.

Where does Gradle write its lock file?

In gradle.lockfile at the root of each project or subproject directory, and buildscript-gradle.lockfile for the build script classpath.

Try it on your own app: the free Shipmark scanner builds a CycloneDX SBOM from a lockfile in the browser and checks it against OSV.dev. Not sure the CRA applies? Use the six-question CRA check.

Sources

This guide summarises Regulation (EU) 2024/2847 for practical use. Tool behaviour was read from each tool's documentation, not tested on your project. It is not legal advice. Related: SBOM for iOS apps · SBOM for Flutter apps · SBOM for React Native apps · Technical documentation.


Originally published on shipmark.eu, where the guide is kept up to date. More guides for app teams: shipmark.eu/guides.

Top comments (0)