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 theLICENSESdeclarations 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:
--strictExit non-zero if any
moz.yamlfails to load, rather than skipping it.--versionVersion to record for the product. Defaults to the configuration’s
MOZ_APP_VERSION_DISPLAY, or tobrowser/config/version_display.txtin an unconfigured tree.--product-nameName to record for the product. Defaults to the configuration’s
MOZ_APP_BASENAME–Firefoxfor desktop,Fennecfor GeckoView – or toFirefoxin an unconfigured tree.--build-toolingDescribe 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.yamlmanifests give a name, an upstream version and revision, a description, upstream URLs and a Bugzilla component, but only exist for libraries thatmach vendormanages.Cargo.lockdescribesthird_party/rust, whichmoz.yamldoes not: one component per third-party crate, with its exact version, apkg:cargopackage URL, the SHA-256 crates.io publishes for the.cratearchive, and the crate-to-crate dependency edges. Licenses, descriptions and URLs come from the vendored crate’s ownCargo.toml. Workspace members are skipped — they are Firefox’s own crates, not third-party code.cargo metadatasupplies whatCargo.lockcannot: whether each crate is reached as a normal, a build or a dev dependency. What only a dev dependency or aTOOLING_MEMBERSmember (sbom_cargo.py: geckodriver, the http3server, the uniffi bindgens…) reaches,mockallorhyperfor 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:npmpackage 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, withthird_party/nodestanding for newtab, whose bundles are built from itsnode_modules.GeckoView’s Maven dependencies, on Android builds: the AndroidX, Play services and other libraries Gradle fetches and every application embedding GeckoView packages. The
writeRuntimeDependenciestask (WriteRuntimeDependenciesin the conventions plugin) writes the resolved runtime closure of the published variant during the build’s Gradle export, unlikegradle/libs.versions.toml, which has neither the transitive dependencies nor which are test-only. Each module becomes apkg:mavencomponent 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-dependenciesdescribes the variant’s runtime closure, in which GeckoView is a single component whose own SBOM describes its contents.LICENSESdeclarations 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 |
|---|---|
|
The manifest this component came from |
|
Where to file bugs |
|
Free-form |
|
|
|
The |
|
For components derived from |
|
The lockfiles that pull in an npm component |
|
The registry name a vendored library declares, and the version resolving it, where |
|
The declared |
|
Dependencies the lockfile names but does not resolve |
|
The checked-in |
|
The lockfile’s integrity string, where it could not be converted to a hash |
|
The POM the metadata comes from, or the artifact the hash is of, could not be read |
|
Only artifacts with a classifier, which the purl does not name, so no hash |
|
A platform or a relocation, which ships nothing |
|
For vendored Python packages: the lockfile and the archive the hash is of |
|
Several pure wheels, so no telling which one was unpacked and no hash |
|
A Python package that declares no license, whose metadata could not be read, or whose vendored copy is another version than the lockfile’s |
|
A lockfile package with no copy under |
|
A package vendored under |
|
A package |
|
Dependencies that are not registry packages, a git or path source for instance |
|
|
|
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.lifecyclesnames thepre-build,buildandpost-buildphases 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:
deventries of apackage-lock.json, and forpnpm-lock.yamlthe closure ofdevDependenciesless 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_MEMBERSmember, which the product document leaves out. Telling them apart needscargo 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.lockgives the version, thepkg:pypipackage URL, the SHA-256 of the archivemach vendor pythonunpacked and the edges; the vendored copy’s metadata gives the license, the summary and the home page. The packagesmach vendor pythonleaves alone, vendored by hand, are described from theirmoz.yamlor their own metadata instead, sovsdownloadis 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.