License Resolution

How BOMHort turns NOASSERTION into a license, which heuristics it applies, what it deliberately refuses to guess, and how every decision is tested.

SBOM generators frequently write NOASSERTION instead of a license: container scanners cannot see a package’s metadata, lockfile-based generators never read it, and some ecosystems have no license field at all. BOMHort fills these gaps at ingest time from public sources. Every rule it applies is listed on this page, including the ones it deliberately does not apply because guessing would turn an unknown license into an approved one without anyone having decided so.

Two principles hold throughout:

  1. A declared license always wins. Resolution only touches packages whose license is NOASSERTION, NONE or empty. It never overrides what the SBOM states. Free-text normalization rewrites how a declared license is spelled, not which license it is.
  2. Every package says where its license came from, or why it has none. The parsing worker stores a license source next to each package license (sbom_packages.package_license_sources, migration 024, #439). An unknown license is no longer a dead end: it carries a reason, so you can tell an actionable gap from one where looking further makes no sense.

Pipeline

The parsing worker runs these steps for every SBOM, before the ClickHouse insert, so the stored package licenses are already final (backend/cmd/parsing-worker/license_provenance.go):

StepWhat happensSource recorded
1. ParseDeclared licenses are read from the SBOM. Broken Yarn Berry PURLs are repaired here (see D7).declared
2. GitHubFor unknown licenses whose PURL maps to a GitHub repository, the repository license is used.github
3. RegistriesStill-unknown packages are offered to the registry resolvers, in this order: npm → NuGet → deps.dev → Packagist → PyPI. The first resolver that returns a license wins.npm, nuget, depsdev, packagist, pypi
4. NormalizeEvery license (declared or resolved) is rewritten to its SPDX form (see D2). If that changed it, +normalized is appended.…+normalized
5. ExplainEvery package that is still unknown gets the reason why (see the vocabulary below).a reason
6. ComplianceFirst-party packages are excluded; everything else is categorized against the license policy.–

Registry results, positive and negative, are cached in registry_license_cache and GitHub results in github_license_cache, so each package version is looked up at most once across all workers and restarts.

License source vocabulary

ValueKindMeaning
declaredoriginThe SBOM itself stated the license.
githuboriginThe GitHub repository license (/repos/{owner}/{repo} and /license API).
npmoriginregistry.npmjs.org version manifest.
nugetoriginNuGet V3 catalog entry (licenseExpression, well-known licenseUrl).
depsdevorigindeps.dev version batch API (Maven, PyPI, Cargo, Go, npm, NuGet).
packagistoriginrepo.packagist.org (Composer).
pypioriginpypi.org JSON API.
latestmodifierThe exact version had no license (or the SBOM gave no usable version), so the newest release’s license was used.
normalizedmodifierFree text such as Apache License 2.0 was rewritten to an SPDX ID.
first-partyreasonThe project’s own code (SBOM root, Yarn workspace, Maven ${project.*} module). Excluded from the compliance check.
no-purlreasonThe SBOM gives no package URL, so there is nothing to look up.
unsupported-ecosystemreasonNo resolver covers the PURL type (generic, deb, apk, oci, …).
not-publishedreasonThe registry does not know the package or version: private, internal, or deleted.
no-license-upstreamreasonThe registry knows the package, but its author declared no license. Legally that means all rights reserved.
unresolvedreasonA resolver covers the package but gave no answer: disabled, rate-limited, network error, or a negative result cached before reasons were recorded. Usually worth a re-scan.

Modifiers are joined with +: depsdev+latest+normalized. SBOMs ingested before BOMHort recorded sources have no value; the API reports them as unrecorded until the next re-scan.

Where you see it

  • License Compliance page → License Resolution (open by default; clicking the Unknown card jumps there):
    • a summary: how many packages the SBOMs left without a license, how many of them BOMHort resolved from public registries (and what share), and how many remain unknown;
    • packages per source, with example package names on hover;
    • a table of the remaining unknowns per reason with package and SBOM counts, example packages and what you can do about each reason. First-party components are listed separately as not counted, because they are excluded from license compliance (D8). The table’s package total equals the plain NOASSERTION entry of the license list; the Unknown card can be slightly higher because it also counts expressions that are only partly unknown, e.g. GPL-2.0-only AND NOASSERTION.
  • SBOM → Dependencies tab: hover a license to see where it came from.
  • API: GET /api/v1/licenses/sources and the license_source field of GET /api/v1/sboms/{id}/dependencies (see the API Reference).

Decision log

Each entry states the decision, why it was taken, what can go wrong, how to turn it off, and which test pins it.

D1. Declared licenses are never overridden

  • Decision: Resolvers only run for NOASSERTION, NONE and empty licenses.
  • Why: The SBOM author knows the artifact; a registry may describe a different build of the package.
  • Risk: A wrong declared license stays wrong. Fix it in the SBOM or with an exception.
  • Test: TestLicensePipelineGolden (declared-* rows).

D2. Normalize only unambiguous free text

  • Decision: Free-text names are rewritten to SPDX IDs only when the spelling names one license unambiguously, usually by including an explicit version (Apache License, Version 2.0, MPL 2.0, GNU General Public License v2 or later). This also applies inside expressions and to raw names returned by registries.
  • Why: An SPDX ID can be matched against the policy; free text cannot.
  • Risk: A spelling that looks unambiguous could hide a modified license text. The rules are deliberately narrow; see the normalization examples.
  • Switch: None. The stored license changes spelling only.
  • Test: internal/license/normalize_test.go, TestDocsLicenseResolutionInSync.

D3. Ambiguous names are not guessed

  • Decision: BSD (which clause count?), Apache Software License (1.1 or 2.0?), Public Domain (no SPDX ID) and similar are kept as declared and categorized unapproved. The same goes for the ambiguous PyPI classifiers License :: OSI Approved :: BSD License and … :: Apache Software License.
  • Why: Mapping them would approve a license nobody approved.
  • Resolve it by: fixing the SBOM, adding the exact ID to the policy, or adding a license exception (see the deployment guide).

D4. Unrecognised names become a LicenseRef, not NOASSERTION

  • Decision: A license name that is not SPDX and not mappable is stored as LicenseRef-<name> (Remix Icon License 1.0 → LicenseRef-Remix-Icon-License-1.0). Composer’s proprietary becomes LicenseRef-proprietary.
  • Why: The package has a declared license; reporting it as unknown would hide that. As a LicenseRef it is unapproved and can be approved by that exact ID.
  • Not a license: URLs and NOASSERTION/NONE are never turned into a LicenseRef. They carry no license name.
  • Test: internal/license/normalize_test.go.

D5. Deprecated SPDX IDs map to their current form

  • Decision: GPL-2.0 → GPL-2.0-only, GPL-2.0+ → GPL-2.0-or-later, GPL-2.0-with-classpath-exception → GPL-2.0-only WITH Classpath-exception-2.0 for categorization (and the LGPL/AGPL equivalents).
  • Why: The SPDX list deprecated them; the policy lists only the current IDs.
  • Test: internal/license/expression_test.go, golden row declared-deprecated.

D6. Expressions fold into one category

  • Decision: AND, OR and WITH are parsed and folded according to LICENSE_EXPRESSION_MODE (strict by default). See Compound SPDX expressions.
  • Test: golden rows declared-or, declared-and.

D7. Repair Yarn Berry PURLs

  • Decision: Lockfile keys copied verbatim into PURLs (pkg:npm/ws@npm:^8.18.0, ws@8.20.0, fsevents@patch:…) are reduced to the resolved name and version at parse time.
  • Why: Neither npm nor OSV can match the raw key, so licenses stayed unknown and vulnerabilities were missed.
  • Test: golden row fsevents.

D8. First-party code is not a dependency

  • Decision: These packages get the reason first-party and are excluded from the compliance check (they stay in the package list):
    • the SBOM’s root package(s);
    • Yarn workspace packages (pkg:npm/…@0.0.0-use.local);
    • Maven modules whose coordinates are unresolved ${project.*} placeholders.
  • Not first-party: other unresolved placeholders such as scalatest_${scala.compat.version}. That is a third-party artifact whose coordinates the generator failed to expand.
  • Why: The project’s own license is not a third-party compliance question, and nobody can look it up in a registry.
  • Test: cmd/parsing-worker/first_party_test.go, golden rows golden-app, @golden/web, golden-core.

D9. Missing versions fall back to the latest release

  • Decision: When the SBOM gives no usable version (pkg:pypi/requests, …@unknown, ${project.version}) or the registry has no license for a Composer dev branch (dev-main), the license of the newest stable release is used and the source gets +latest.
  • Why: Licenses rarely change between releases, and without a version there is nothing more precise to ask.
  • Risk: A project that relicensed (for example from Apache-2.0 to BSL) is reported with its current license. Check +latest entries if this matters.
  • Exception: PyPI does not fall back when a concrete version is missing on PyPI. PyPI names are global, so a missing version usually means a different, private package with the same name.
  • Test: npm, depsdev, packagist and pypi resolver tests, golden rows npm-versionless, monolog/monolog.

D10. Resolver order and precedence

  • Decision: GitHub first, then npm → NuGet → deps.dev → Packagist → PyPI. The first license found wins. Native registries run before deps.dev for their own ecosystem. PyPI runs last, so it only sees what deps.dev could not map.
  • Why: The ecosystem’s own registry is authoritative. deps.dev covers several ecosystems with one batch call. PyPI’s free-text fields need the most heuristics, so they are only consulted when nothing better exists.
  • Test: TestLicensePipelineGolden (row typing-extensions: deps.dev had nothing, PyPI did), TestDocsLicenseResolutionInSync (resolver order).

D11. PyPI field precedence

  • Decision: license_expression (PEP 639) → an explicit classifier → SPDX table → a short free-text license field via D2 / D4 → the first unmapped classifier as a LicenseRef. Long license fields (full license texts pasted into metadata) are ignored.
  • Test: internal/pypi/resolver_test.go.

D12. Multiple licenses: AND vs OR

  • Decision: Packagist’s license array is joined with OR, because Composer defines it as a choice. deps.dev’s list is joined with AND, because it lists every license the package declares.
  • Test: packagist and depsdev resolver tests.

D13. Negative results are cached with their reason

  • Decision: A package that a registry does not know, or that declares no license, is cached as a negative result with its reason, so it is not looked up again on every ingest and the reason survives restarts.
  • Risk: A package that gets published or licensed later stays negative until its cache row is removed (see Operations).
  • Test: npm, depsdev, packagist, pypi resolver tests.

D14. gopkg.in import paths map to their GitHub repository

  • Decision: Besides the well-known table, gopkg.in/pkg.vN → github.com/go-pkg/pkg and gopkg.in/user/pkg.vN → github.com/user/pkg, following gopkg.in’s documented redirect rule.
  • Test: internal/github/purl_test.go.

D15. A GitHub rate limit defers the job; nothing is guessed

  • Decision: When GitHub answers 429, or 403 with X-RateLimit-Remaining: 0 / Retry-After, the resolver returns an error instead of a result. The worker stops processing that SBOM before any registry resolver runs and parks the job (ingestion_queue.retry_after, error_message = github rate limit …) until the reset time GitHub reports (X-RateLimit-Reset, else Retry-After, else one hour). Nothing is cached for the package, the worker does not sleep, and the remaining jobs in the queue keep flowing. Only a rate limit is treated this way — a plain 403 or 404 is still a negative answer (D13).
  • Why: The previous behaviour slept inside the worker for up to an hour and then recorded "" as the GitHub answer, which was cached as a negative. The registry resolvers then answered instead — and deps.dev answers by scanning the files a module ships, so a license-detection library that bundles the SPDX corpus (google/licenseclassifier/v2) came back as 148 licenses AND-ed together, while its LICENSE is Apache-2.0. A missing answer is not an answer; a resolver that is temporarily unable to reply must not let a lower-precedence one speak for it (D10). Capping the number of licenses would have been a guess about which multi-license results are real; it is not done.
  • Risk: Without a GITHUB_TOKEN (60 req/h) an instance with many unresolved packages makes slow progress: each deferral waits for the reset. Set the token (5,000 req/h); see Operations.
  • Switch: SKIP_GITHUB_RESOLVE=true — without the GitHub pass there is nothing to defer for.
  • Test: internal/github/ratelimit_test.go, cmd/parsing-worker/license_provenance_test.go (TestResolvePackageLicenses_GitHubRateLimitAborts), internal/clickhouse/queue_defer_integration_test.go.

Deliberately not guessed

InputStaysWhy
BSD, BSD Licenseunapproved2-clause, 3-clause and 4-clause differ legally
Apache Software LicenseunapprovedApache-1.1 or Apache-2.0
Public DomainunapprovedNo SPDX ID; jurisdictions differ
GPL, LGPL without a versionunapproved-only vs -or-later, version 2 vs 3
A license URLunknownA URL can point anywhere and change over time
The latest release’s license for a missing PyPI versionunknownProbably a private package with the same name
A Maven parent POM’s licenseunknownNot implemented, see below

Known limitations

  • Maven parent POM inheritance. Many Maven artifacts inherit their license from a parent POM, and deps.dev reports no license for them (org.apache.commons:commons-pool2:2.13.1). Resolving parent chains would need a Maven-specific resolver. Such packages show no-license-upstream.
  • Artifacts outside Maven Central (for example Confluent’s repository) are not-published from deps.dev’s point of view.
  • NuGet does not report reasons yet; unresolved NuGet packages show unresolved.
  • Archived-repository view. GET /api/v1/packages/archived maps repositories back to packages using the well-known module table only, not the generic gopkg.in rule from D14.
  • Cache entries never expire. Licenses can change upstream; see Operations for how to refresh them.

Normalization examples

How a license name returned by a registry is stored (license.Recover). Each row is checked by TestDocsLicenseResolutionInSync against the code and the built-in default policy in strict mode. Change the code and this table together.

A license declared in the SBOM goes through the same spelling rules (license.Normalize) with one difference: a name that matches no rule is kept verbatim (Public Domain) instead of becoming a LicenseRef. The category is the same either way, which the test also checks.

Registry textStored asCategory
Apache License, Version 2.0Apache-2.0permissive
The Apache Software License, Version 2.0Apache-2.0permissive
The MIT LicenseMITpermissive
ExpatMITpermissive
New BSD LicenseBSD-3-Clausepermissive
Simplified BSD LicenseBSD-2-Clausepermissive
Eclipse Distribution License - v 1.0BSD-3-Clausepermissive
Go LicenseBSD-3-Clausepermissive
Bouncy Castle LicenceMITpermissive
Python Software Foundation LicensePSF-2.0permissive
MPL 2.0MPL-2.0copyleft
EPL 2.0EPL-2.0copyleft
GNU General Public License v2 or laterGPL-2.0-or-latercopyleft
GPL-2.0GPL-2.0-onlycopyleft
GPL-2.0+GPL-2.0-or-latercopyleft
GNU General Public License, version 2 with the GNU Classpath ExceptionGPL-2.0-only WITH Classpath-exception-2.0copyleft
MIT OR GPL-3.0-onlyMIT OR GPL-3.0-onlypermissive
MIT AND GPL-3.0-onlyMIT AND GPL-3.0-onlycopyleft
CC BY-SA 4.0CC-BY-SA-4.0unapproved
CDDL 1.1CDDL-1.1unapproved
BSDBSDunapproved
Apache Software LicenseLicenseRef-Apache-Software-Licenseunapproved
Public DomainLicenseRef-Public-Domainunapproved
Remix Icon License 1.0LicenseRef-Remix-Icon-License-1.0unapproved
proprietaryLicenseRef-proprietaryunapproved
https://example.com/LICENSENOASSERTIONunknown

Categories depend on your license policy. The table uses the built-in default, so CC-BY-SA-4.0 or CDDL-1.1 may well be approved in yours.

Worked examples

One synthetic SBOM covers every path through the pipeline: backend/cmd/parsing-worker/testdata/license_golden.spdx.json. TestLicensePipelineGolden runs it through the real parser, the real pipeline (with recorded registry answers) and the real policy. The table below must match that test’s expectations exactly; TestDocsLicenseResolutionInSync fails otherwise.

PackageCaseLicenseSourceCategoryCompliance check
golden-appSBOM rootNOASSERTIONfirst-partyunknownexcluded
declared-mitDeclared SPDX IDMITdeclaredpermissivechecked
declared-freetextDeclared Apache License 2.0Apache-2.0declared+normalizedpermissivechecked
declared-deprecatedDeclared GPL-2.0+GPL-2.0-or-laterdeclared+normalizedcopyleftchecked
declared-orChoiceMIT OR GPL-3.0-onlydeclaredpermissivechecked
declared-andConjunctionMIT AND GPL-3.0-onlydeclaredcopyleftchecked
declared-customCustom licenseLicenseRef-Remix-Icon-License-1.0declaredunapprovedchecked
github.com/acme/libGo module on GitHubMITgithubpermissivechecked
npm-pkgnpm, exact versionISCnpmpermissivechecked
npm-versionlessnpm, no versionMITnpm+latestpermissivechecked
fseventsYarn patch: PURLMITnpmpermissivechecked
commons-lang3Maven via deps.devApache-2.0depsdevpermissivechecked
jakarta.xml.bind-apiRaw POM license nameBSD-3-Clausedepsdev+normalizedpermissivechecked
monolog/monologComposer dev-mainMITpackagist+latestpermissivechecked
typing-extensionsdeps.dev empty, PyPI has itPSF-2.0pypipermissivechecked
internal-uiPrivate npm packageNOASSERTIONnot-publishedunknownchecked
@golden/webYarn workspaceNOASSERTIONfirst-partyunknownexcluded
golden-coreMaven ${project.groupId} moduleNOASSERTIONfirst-partyunknownexcluded
legacy-toolPublished without a licenseNOASSERTIONno-license-upstreamunknownchecked
libc.so.6pkg:genericNOASSERTIONunsupported-ecosystemunknownchecked
vendored-blobNo PURLNOASSERTIONno-purlunknownchecked
flaky-crateRegistry errorNOASSERTIONunresolvedunknownchecked

Operations

SettingEffect
SKIP_GITHUB_RESOLVE=trueDisable step 2
SKIP_NPM_RESOLVE, SKIP_NUGET_RESOLVE, SKIP_DEPSDEV_RESOLVE, SKIP_PACKAGIST_RESOLVE, SKIP_PYPI_RESOLVEDisable one registry resolver
LICENSE_EXPRESSION_MODEHow expressions fold (D6)

Resolution happens at ingest. After changing settings or upgrading, re-process the SBOMs (make re-scan locally, or a re-ingest on Kubernetes, see the deployment guide).

To re-check packages that were negative, delete their cache rows before the re-scan:

-- negatives cached before reasons were recorded
ALTER TABLE registry_license_cache DELETE WHERE spdx_id = '';
-- all negatives of one registry
ALTER TABLE registry_license_cache DELETE WHERE registry = 'pypi' AND startsWith(spdx_id, '!');

Deferred jobs. A job parked by a GitHub rate limit (D15) stays pending with a retry_after in the future and the reason in error_message; the worker picks it up again on its own once the time has passed. To see what is waiting and until when:

SELECT job_id, source_file, retry_after, error_message
FROM (
    SELECT job_id, argMax(source_file, created_at) AS source_file,
           argMax(status, created_at) AS status,
           argMax(retry_after, created_at) AS retry_after,
           argMax(error_message, created_at) AS error_message
    FROM ingestion_queue GROUP BY job_id
)
WHERE status = 'pending' AND retry_after > now();

Values recorded before D15. Instances that ran without a GITHUB_TOKEN before BOMHort 0.8 may hold negatives in github_license_cache that were really rate limits, and registry answers that only won because of them. Delete both and re-scan:

-- GitHub negatives (a real "no license" answer is re-fetched for one request)
ALTER TABLE github_license_cache DELETE WHERE spdx_id = '';
-- deps.dev answers built from shipped license texts rather than the declared license
ALTER TABLE registry_license_cache DELETE WHERE registry = 'depsdev' AND position(spdx_id, ' AND ') > 0;

Tests

TestCovers
cmd/parsing-worker/golden_test.goEnd-to-end: parser → resolvers → normalization → reasons → policy → compliance exclusion
cmd/parsing-worker/docs_sync_test.goThis page’s vocabulary, resolver order, normalization and worked-example tables match the code
cmd/parsing-worker/first_party_test.goFirst-party detection (D8)
internal/license/normalize_test.go, expression_test.goNormalization, deprecated IDs, expression folding
internal/{npm,nuget,depsdev,packagist,pypi}/*_test.goEach registry’s parsing, version fallback and negative reasons (recorded HTTP responses)
internal/github/purl_test.goPURL → repository mapping
internal/github/ratelimit_test.go, internal/clickhouse/queue_defer_integration_test.goRate limit → ErrRateLimited without sleep or cache; deferred job invisible to ClaimJobs until retry_after (D15)