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.

What Becomes a Component

Components come from three 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. Fifteen crates today are test-only, mockall and expect-test among them, and ship in nothing. The kinds are collected and reported on stderr but not yet written to the document; expressing them as CycloneDX scope is the obvious next step. They need the objdir’s generated cargo config, so an unconfigured tree collects nothing and says so.

  • 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, or third_party/rust/<crate> — and license:<notice-id> for a component that came from a notice alone.

Package URLs are pkg:cargo for crates, 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 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, 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: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.

In Automation

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

public/build/sbom.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 to the top-level moz.build. The build graph schedules it like any other generated file, and automation/upload picks the result up. The generator is strict there, so an unparseable moz.yaml 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.