Third-Party Licensing and the SBOM

Firefox ships a large amount of third-party code. Two artifacts describe it:

  • about:license, the user-visible attribution page, rendered at build time from the LICENSES declarations spread across the tree.

  • A CycloneDX software bill of materials, generated by ./mach sbom, which describes the same code in a machine-readable form suitable for vulnerability scanners and compliance tooling.

Both read from the same declarations, so a notice added for about:license also appears in the SBOM.

Declaring a License

A directory declares the notices it owns with the LICENSES and LICENSED_UNDER moz.build variables. LICENSES introduces a notice and its text; LICENSED_UNDER says which code that notice covers:

LICENSES += ["harfbuzz"]
LICENSES["harfbuzz"].title = "HarfBuzz License"
LICENSES["harfbuzz"].text = "LICENSE-NOTICE.txt"

LICENSED_UNDER += ["MIT"]
LICENSED_UNDER["MIT"].paths = ["vendor/lodash.js"]

The full reference for both variables, including how a notice’s text is escaped and how shared notices are declared once in toolkit/content/licenses/, is in the mozbuild symbol reference.

What paths Covers

LICENSED_UNDER += ["MIT"] on its own attributes the whole declaring directory to the MIT notice, and that is the common case. The paths flag narrows that to the entries listed, leaving the rest of the directory unattributed:

# every file in this directory is under MIT
LICENSED_UNDER += ["MIT"]

# only these two are; the rest of the directory is not
LICENSED_UNDER += ["MIT"]
LICENSED_UNDER["MIT"].paths = ["vendor/lodash.js", "vendor/react*"]

Entries are relative to the declaring moz.build, may be shell-style globs, and a directory stands for its whole subtree. They do not have to be part of the build: this is how a subtree the build system never traverses, a crate under third_party/rust for instance, gets attributed from its nearest built ancestor. Whatever is listed is joined onto the declaring directory, so what reaches licenses.json, about:license and the SBOM is always topsrcdir-relative.

LICENSES has a paths flag too, with the same meaning but a different base: it is relative to the top source directory, because a shared notice is declared far from the code it covers:

# in toolkit/content/licenses/moz.build
LICENSES["apache"].paths = ["third_party/perfetto"]

The two contribute to the same list, so a notice’s heading in about:license shows the union of its own paths and every LICENSED_UNDER naming its id. Prefer LICENSED_UNDER, which lives next to the code and moves with it; LICENSES["x"].paths is for the shared notices that have nowhere local to be declared.

Because a directory is only traversed in configurations that build it, a notice is only recorded in builds that actually ship the code it covers. This replaces the per-entry #ifdefs the hand-written license.html used to carry.

The build backend aggregates every declaration reachable in the current configuration into <objdir>/licenses.json, which is what both about:license and the SBOM consume.

For the same reason, a LICENSED_UNDER id whose LICENSES declaration lives in a directory this configuration does not traverse is dropped rather than treated as an error: a JS shell build reaches js/ and intl/ but never toolkit/content/licenses/. An id that is declared nowhere at all is a typo, and the license-declarations linter catches it because it reads every moz.build in the tree regardless of configuration:

./mach lint -l license .

Declaring the same id twice is a build failure, reported by the backend.

The same linter validates every spdx flag as an SPDX license expression, so Apache2 or BSD-3 is reported rather than shipped in the SBOM. Compound expressions such as MIT OR Apache-2.0 are accepted, and a license with no SPDX id can use LicenseRef-<name>.

It also reports an spdx flag declared inside a vendored library whose moz.yaml already sets origin.license. That field is the one the SBOM reports, so the flag would be a second copy of the same fact with nothing keeping the two equal – and they had already drifted for media/libyuv, declared BSD-3-Clause in moz.build against BSD-3-Clause-Clear in moz.yaml. Drop the flag and let moz.yaml answer. The exception is a notice covering code whose license differs from the library’s own, such as the MySpell files inside hunspell, which says so explicitly and keeps its flag:

LICENSES["myspell"].spdx = "BSD-2-Clause"
LICENSES["myspell"].subcomponent = True

The flag also reaches the SBOM: the enclosing library’s component carries the subcomponent’s expression alongside the one moz.yaml declares, instead of moz.yaml’s answer standing for code it does not cover.

The notice text duplicates the same way. A vendored library already names the file it ships its license in, in origin.license-file, so leave text unset and the notice is read from there:

origin:
  license: MIT
  license-file: COPYING
LICENSES += ["expat"]
LICENSES["expat"].title = "Expat License"

The manifest is found by walking up from the declaring moz.build’s directory, so this only works when the moz.build sits in the library’s directory or below it. A moz.build that builds libraries vendored into its subdirectories sits above their manifests, so it sets text to each library’s license file instead.

The linter reports a text naming the file that manifest already names. Where moz.yaml has no license-file, add it rather than copying the text into a LICENSE-NOTICE.txt next to the moz.build: the manifest is what mach vendor checks against upstream, so it is the copy that stays current.

A separate notice file earns its place when it is not a copy of a single shipped file. media/libvpx needs one because its notice is LICENSE plus the VP8 patent grant, which upstream keeps in libvpx/PATENTS; netwerk/sctp needs one because its notice reproduces the FreeBSD and Cisco copyright headers its sources carry, which upstream’s LICENSE.md does not.

Rendering about:license Without a Build

GENERATED_FILES renders the page during a build, but the whole page can also be produced directly from the tree:

./mach licenses -o /tmp/license.html

This is the page the current configuration would ship. The tree has to be configured, because the configuration decides which directories are traversed and so which notices exist, but nothing beyond ./mach configure is needed: the command reads the moz.build files itself rather than waiting for a build to write licenses.json.

The command deliberately offers no machine-readable output. That is what mach sbom is for.

Generating the SBOM

./mach sbom -o /tmp/sbom.json

The output is CycloneDX JSON. Useful arguments:

--strict

Exit non-zero if any moz.yaml fails to load, rather than skipping it.

--version

Version to record for the product. Defaults to the configuration’s MOZ_APP_VERSION_DISPLAY, or to browser/config/version_display.txt in an unconfigured tree.

--product-name

Name to record for the product. Defaults to the configuration’s MOZ_APP_BASENAME – Firefox for desktop, Fennec for GeckoView – or to Firefox in an unconfigured tree.

--build-tooling

Describe the third-party code that builds and tests the tree instead of what the product ships. See The Build Tooling SBOM.

What Becomes a Component

Components come from four sources, because none alone covers the tree:

  • moz.yaml manifests give a name, an upstream version and revision, a description, upstream URLs and a Bugzilla component, but only exist for libraries that mach vendor manages.

  • Cargo.lock describes third_party/rust, which moz.yaml does not: one component per third-party crate, with its exact version, a pkg:cargo package URL, the SHA-256 crates.io publishes for the .crate archive, and the crate-to-crate dependency edges. Licenses, descriptions and URLs come from the vendored crate’s own Cargo.toml. Workspace members are skipped — they are Firefox’s own crates, not third-party code.

    cargo metadata supplies what Cargo.lock cannot: whether each crate is reached as a normal, a build or a dev dependency. What only a dev dependency or a TOOLING_MEMBERS member (sbom_cargo.py: geckodriver, the http3server, the uniffi bindgens…) reaches, mockall or hyper for instance, ships in nothing and goes to the build tooling document. A member missing from that list counts as shipped. An unconfigured tree has no kinds, so it keeps every crate in the product document.

  • The npm lockfiles of the bundled front-end code describe what webpack folds into the newtab, aboutwelcome and asrouter bundles: React, Redux, Fluent and their closures. These packages leave no directory of their own, so nothing else in the tree describes them. Each becomes a component with a pkg:npm package URL, the SHA-512 the registry publishes for the tarball, the license the package declares and the package-to-package edges.

    Only the runtime closure is reported; the webpack and babel toolchain that makes up most of a lockfile goes to the build tooling document. The lockfiles are an allowlist in sbom_npm.py, with third_party/node standing for newtab, whose bundles are built from its node_modules.

  • GeckoView’s Maven dependencies, on Android builds: the AndroidX, Play services and other libraries Gradle fetches and every application embedding GeckoView packages. The writeRuntimeDependencies task (WriteRuntimeDependencies in the conventions plugin) writes the resolved runtime closure of the published variant during the build’s Gradle export, unlike gradle/libs.versions.toml, which has neither the transitive dependencies nor which are test-only. Each module becomes a pkg:maven component with its artifact’s SHA-256, its POM’s licenses and its edges.

    Fenix is built by Gradle alone, in its own tasks, so its nightly, beta and release APK builds publish a separate public/build/sbom.json: mach sbom --gradle-runtime-dependencies describes the variant’s runtime closure, in which GeckoView is a single component whose own SBOM describes its contents.

  • LICENSES declarations cover everything else: one component per notice whose paths no manifest or crate already covers, carrying the notice id and the SPDX expression where one is known.

Where several describe the same code they are merged: the notice ids land on the manifest’s or the crate’s component, and where neither declared a license the notice’s SPDX expression fills the gap.

A handful of notices name no path at all — the MPL, the bundled spellchecking dictionaries, jQuery and React among them — so they cannot attach to a component. Those go on the root component instead.

The result is a superset of about:license: every notice id and every attributed path is represented.

Identifiers, Evidence and the Graph

Each component’s bom-ref is the topsrcdir-relative path it was derived from — the manifest’s directory, third_party/rust/<crate> or third_party/python/<package> — and license:<notice-id> for a component that came from a notice alone. A bundled npm package has no directory, so it is npm:<name>@<version>: the version is part of the identity because a package can ship at several versions at once. A Maven module is maven:<group>:<name>@<version> for the same reason, and a Python package the lockfile names but the tree does not vendor pypi:<name>@<version>.

Package URLs are pkg:cargo for crates, pkg:pypi for Python packages, pkg:npm for anything a lockfile or an npm-name declaration identifies, pkg:maven for GeckoView’s Gradle dependencies, and pkg:github or pkg:gitlab where a manifest’s upstream repository is recognised, since pkg:generic matches nothing in OSV.dev or the GitHub Advisory Database; everything else keeps the upstream repository in a vcs_url qualifier. A notice-derived component gets no package URL at all: it is a set of files in our own tree, not a package any ecosystem can resolve, and a pkg:generic/<basename> would match nothing while looking like it might.

A vendored library that is also published on npm says so in its moz.yaml:

origin:
  npm-name: "@quartzy/prosemirror-suggestions"

The name is declared because nothing else in the tree gives it reliably. A vendored package.json decides the version; a name there that disagrees leaves the purl alone and records moz:npm.name-mismatch.

A notice-derived component records the files it covers as CycloneDX evidence.occurrences, one entry per path, rather than as one sibling component per file: thirty files under one notice are one piece of third-party code, and splitting them would bury the real libraries.

dependencies is a real graph wherever something knows one. The crates depend on each other as Cargo.lock says, the npm packages as their lockfiles do, and everything no other component depends on hangs off the root, so a viewer that draws the graph — CycloneDX Sunshine, for instance — shows the crate tree rather than one flat ring of siblings.

Mozilla-Specific Properties

Metadata CycloneDX has no field for is recorded as moz:-prefixed properties:

Property

Meaning

moz:moz-yaml.path

The manifest this component came from

moz:bugzilla.product, moz:bugzilla.component

Where to file bugs

moz:origin.release, moz:origin.notes

Free-form origin fields

moz:vendoring.source-hosting, moz:vendoring.vendor-directory

vendoring fields

moz:license.notice-ids

The about:license notices covering this component

moz:cargo.source, moz:cargo.license-file

For components derived from Cargo.lock

moz:npm.manifests

The lockfiles that pull in an npm component

moz:npm.name, moz:npm.version

The registry name a vendored library declares, and the version resolving it, where mach vendor tracks a git revision instead

moz:npm.name-mismatch

The declared npm-name disagrees with the vendored package.json; the purl was left alone

moz:npm.unresolved-dependencies

Dependencies the lockfile names but does not resolve

moz:npm.package-json-unreadable

The checked-in package.json the license or version comes from could not be read

moz:npm.integrity-unreadable

The lockfile’s integrity string, where it could not be converted to a hash

moz:maven.pom-unreadable, moz:maven.artifact-unreadable

The POM the metadata comes from, or the artifact the hash is of, could not be read

moz:maven.classified-artifacts

Only artifacts with a classifier, which the purl does not name, so no hash

moz:maven.no-artifact

A platform or a relocation, which ships nothing

moz:pypi.lockfile, moz:pypi.artifact

For vendored Python packages: the lockfile and the archive the hash is of

moz:pypi.ambiguous-artifacts

Several pure wheels, so no telling which one was unpacked and no hash

moz:pypi.license-file, moz:pypi.metadata-unreadable, moz:pypi.vendored-version

A Python package that declares no license, whose metadata could not be read, or whose vendored copy is another version than the lockfile’s

moz:pypi.not-vendored

A lockfile package with no copy under third_party/python

moz:pypi.not-in-lockfile

A package vendored under third_party/python by hand, which uv.lock does not name

moz:pypi.vendoring-excluded

A package mach vendor python skips, stubbed or patched in the tree, so no hash

moz:pypi.unresolved-dependencies

Dependencies that are not registry packages, a git or path source for instance

moz:license.conjunction

unspecified where a manifest declares several licenses; moz.yaml has no AND/OR operator, so the SBOM records the ambiguity rather than inventing a legal fact

moz:source.revision

On the root component

Reproducibility

Two runs over the same checkout produce byte-identical output. The BOM’s serial number is derived from the source revision, and the timestamp defaults to the head commit time rather than the wall clock. SOURCE_DATE_EPOCH overrides the timestamp where release engineering sets it.

Without a Configured Objdir

licenses.json is written by the build backend, so on an unconfigured tree the SBOM is built from moz.yaml alone and the command says so on stderr. That covers vendored libraries only, considerably less than about:license describes. Run ./mach build-backend for the complete picture.

The Build Tooling SBOM

./mach sbom --build-tooling -o /tmp/sbom-build-tooling.json

The product document describes what ships. The tree also runs a good deal of third-party code that ships in nothing – the webpack that builds newtab’s bundles, the test harnesses – and that is as much a supply-chain input, so it gets a document of its own rather than being folded into the product’s, where it would read as shipped.

It is a separate CycloneDX document of the same shape: the same root component, product name and version, and the same identifiers and package URLs. What sets it apart is machine-readable:

  • metadata.lifecycles names the pre-build, build and post-build phases rather than leaving the phase implied; the test harnesses run once the product is built.

  • Every component has scope: excluded, CycloneDX’s word for “not part of the runtime”.

  • The serial number is derived from the source revision as well, but differs from the product document’s, so the two stay distinct for the same checkout.

It holds:

  • The npm packages of the lockfiles the product document reads that are not in the runtime closure: dev entries of a package-lock.json, and for pnpm-lock.yaml the closure of devDependencies less anything a runtime dependency already reaches. A package that both ship and build use is described by the product document only.

  • The test and tooling crates, reached from the workspace only as dev dependencies or from a TOOLING_MEMBERS member, which the product document leaves out. Telling them apart needs cargo metadata, so an unconfigured tree has none here.

  • The vendored Python packages, which mach, the build system and the test harnesses run on. third_party/python/uv.lock gives the version, the pkg:pypi package URL, the SHA-256 of the archive mach vendor python unpacked and the edges; the vendored copy’s metadata gives the license, the summary and the home page. The packages mach vendor python leaves alone, vendored by hand, are described from their moz.yaml or their own metadata instead, so vsdownload is here rather than in the product document.

Shippable builds generate it next to the product document; see In Automation.

In Automation

Shippable builds generate the SBOM and the build tooling SBOM as part of the build and upload them alongside the other build artifacts:

public/build/sbom.json
public/build/sbom-build-tooling.json

MOZ_GENERATE_SBOM: "1" in a build task’s worker.env turns on the configure option of the same name, which adds a GENERATED_FILES entry for <objdir>/sbom.json and one for <objdir>/sbom-build-tooling.json to the top-level moz.build. The build graph schedules them like any other generated file, and automation/upload picks the results up. The generator is strict there, so an unparseable moz.yaml, or a build tooling document without cargo metadata to tell the tooling crates apart, fails the build rather than silently shrinking the SBOM. The variable is set per task in taskcluster/kinds/build/, so whether a given build produces an SBOM is visible in the task definition.

Generating it per build, rather than once for the tree, is what makes the result correct: the declarations reachable in a configuration are the ones that configuration ships, so each platform’s artifact describes that platform. On macOS that means the per-architecture build tasks, not the universal task that only recombines their output. The usual index routes reach the latest one, for example:

https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/gecko.v2.mozilla-central.shippable.latest.firefox.linux64-opt/artifacts/public/build/sbom.json

about:license is not published separately; it ships inside the browser.