In short

License compliance starts from the SBOM of what you ship, not the dependency manifest. Record each component's license as an SPDX expression, and decide by distribution model: permissive licenses need notices, weak copyleft (MPL, LGPL) needs source for the covered files or library, GPL needs complete source when you distribute binaries, and AGPL also applies to network use. Encode an allow, review and deny policy, gate pull requests and releases on it, and ship third-party notices with every release. This is engineering guidance; have counsel approve the policy.

  • Use SPDX identifiers and expressions: MIT OR Apache-2.0 lets you choose either, MIT AND BSD-3-Clause means both apply, and WITH adds an exception's permissions.
  • Declared licenses come from package metadata; concluded licenses come from analysis. Treat missing, NOASSERTION and unknown values as review, never as allowed.
  • Obligations depend on the trigger: most attach to distribution; AGPL-3.0 also attaches to users interacting over a network; internal use rarely triggers any.
  • SSPL, BUSL-1.1 and the Elastic License 2.0 are not OSI-approved open source licenses, and projects such as Terraform, Redis and Elasticsearch changed licenses in new versions.
  • Gate on allow-lists: an allow-list fails on licenses nobody has reviewed, while a deny-list silently passes them.
  • Ship notices: keep copyright and license texts, Apache NOTICE contents and written source offers with the release, not only in the repository.

Verify it

  1. Run the license gate or trivy fs --scanners license on the release SBOM and confirm no component is unknown, NOASSERTION or denied without a recorded exception.
  2. Compare declared and concluded licenses for your top dependencies, and investigate any that differ.
  3. Confirm the dependency review workflow uses allow-licenses and fails on a test pull request that adds a GPL-3.0 package.
  4. Open the shipped product and check that the third-party notices list matches the release SBOM.
  5. For each GPL or LGPL component in a distributed product, find the archived source for the shipped version.
Applies to

Teams that build software containing third-party open source components and release it internally, as a hosted service, as downloadable software or on devices. Not legal advice; license interpretation for a specific product belongs with counsel.

Part of
Workflow: Build an AppSec program
Learning goals and prerequisitesAbout 29 minutes: 9 to read and 20 for the exercise

What you will learn

  • Read SPDX license expressions, including OR, AND, WITH and deprecated identifiers.
  • State what triggers the main obligations of permissive, weak copyleft, strong copyleft and network copyleft licenses.
  • Write a license policy that depends on how the product is distributed.
  • Enforce the policy from the SBOM in CI and produce notices and source offers.

Before you start

Engineers, release managers and AppSec or open source program office staff who ship products containing open source and must show which licenses apply

  • Generate an SBOM for a build, or read one in CycloneDX JSON.
  • Know how your product reaches users: internal tool, hosted service, downloadable binary or device firmware.
  • For the code: Python 3 and Syft or Trivy.

Refresh first: Generate an SBOM for the software you actually release

Go to the guided practice

Which licenses do you actually ship?

Start from the artifact. The licenses that bind you are those of the components in what you distribute or run, which a build-time SBOM records, plus code that was copied into your repository without a package manager. A manifest misses vendored code, bundled assets and the base image; /domains/supply-chain/sbom/generate-release-inventory/ covers producing an SBOM that matches the release.

Each component can carry three kinds of license information. The declared license is what the author's package metadata claims. The concluded license is what someone determined after examining the files, which can differ when a package bundles code under other licenses or ships a license file that contradicts its metadata. Observed licenses are the evidence a scanner such as ScanCode Toolkit found in the files. CycloneDX 1.6 and later mark each license entry as declared or concluded with acknowledgement; SPDX documents have separate declared and concluded fields. A value of NOASSERTION, or no license at all, means nobody has established the terms, and code with no license grants no rights to copy or distribute it.

Read SPDX license expressions

The SPDX License List, version 3.29.0 on 2026-09-16, gives each license and exception a short identifier. A license expression combines them. OR means the licensee may choose one of the options, AND means all apply at once, WITH attaches an exception that grants extra permissions, and a + suffix means that version or any later one. WITH binds tighter than AND, which binds tighter than OR, and parentheses override that order. License identifiers match case-insensitively, but the operators are case-sensitive: write them in upper case (all lower case is also accepted).

Some identifiers are deprecated because they were ambiguous. GPL-2.0 did not say whether later versions were allowed, so current lists use GPL-2.0-only and GPL-2.0-or-later; the same applies to the LGPL, AGPL and GFDL families. Licenses that are not on the list are written as LicenseRef- followed by your own name, with the license text included in the SBOM; treat them as needing review.

Read SPDX license expressions
ExpressionWhat it means for you
MITOne license; keep the copyright and permission notice.
MIT OR Apache-2.0Choose either. Many Rust crates use this; pick the one your policy prefers and record the choice.
MIT AND BSD-3-ClauseBoth apply, usually because the package contains code under each; meet both sets of conditions.
GPL-2.0-only WITH Classpath-exception-2.0GPL-2.0, plus permission to link independent modules without applying the GPL to them; decide it as its own policy entry.
MIT AND (LGPL-2.1-or-later OR BSD-3-Clause)MIT always applies, plus your choice of LGPL-2.1 or later or BSD-3-Clause.
LicenseRef-acme-eulaA license not on the SPDX list; read the included text.
NOASSERTION or emptyUnknown terms; not usable until reviewed.

What each common license asks of you

Obligations attach to an action, usually distribution: giving a copy of the software, as a download, an app, an installer, a container image you hand to customers or firmware on a device. Running software internally or as a hosted service is usually not distribution, which is why the network clause in AGPL-3.0 matters. The table summarizes the main conditions; read the license text for the exact wording, and ask counsel about anything that affects a product decision.

What each common license asks of you
License (SPDX)KindTriggered byMain obligations
MIT, ISCPermissiveDistributionInclude the copyright notice and the license text.
BSD-2-Clause, BSD-3-ClausePermissiveDistributionKeep the notice, conditions and disclaimer in source, and reproduce them in documentation for binaries; BSD-3-Clause also forbids using contributors' names to endorse your product.
Apache-2.0Permissive with patent grantDistributionInclude the license, keep notices, mark files you changed, and pass on the contents of any NOTICE file. The patent license ends for anyone who sues claiming the work infringes a patent.
MPL-2.0Weak (file-level) copyleftDistributionMake the source of MPL-licensed files, including your changes to them, available under MPL; your other files can stay under your terms.
LGPL-2.1, LGPL-3.0Weak (library) copyleftDistributionProvide the library's source, including changes, and let users replace the library with a modified version, which is simple with dynamic linking and needs object files for static linking.
GPL-2.0, GPL-3.0Strong copyleftDistributionProvide the complete corresponding source of the whole work based on the program under the same license. GPL-3.0 adds installation information for consumer devices and explicit patent terms.
AGPL-3.0Network copyleftDistribution, or modifying it and letting users interact with it over a networkAs GPL-3.0, plus offering the source of your modified version to its network users.
EPL-2.0Weak (module-level) copyleftDistributionMake the source of EPL-licensed code, including your modifications, available; separate modules can use other licenses.
SSPL-1.0, BUSL-1.1, Elastic-2.0Source-available, not OSI-approvedVaries; usually offering the software as a service or competing with the vendorSSPL requires releasing the source of the whole service stack if you offer the program as a service; BUSL-1.1 limits production use until a change date; Elastic-2.0 forbids providing it as a managed service.

Write a license policy by distribution model

A license that is fine for an internal tool can be a problem in firmware you sell, so write the policy per distribution model and let each product declare its model. Use three outcomes: allow without review, review by someone who owns licensing decisions, and deny unless legal approves an exception. Put unknown, NOASSERTION and LicenseRef- values in review, and give every exception an owner and a scope.

Recheck licenses on upgrade, not only on first use, because projects change licenses in new versions. HashiCorp moved future releases of Terraform and its other products from MPL-2.0 to BUSL-1.1 on 2023-08-10, and the OpenTofu fork continued from the last MPL release. Redis moved to a choice of its source-available license and SSPL in March 2024 and added AGPL-3.0 as a further option with Redis 8 on 2025-05-01. Elastic relicensed Elasticsearch from Apache-2.0 to SSPL and the Elastic License in 2021 and added AGPL-3.0 on 2024-08-29. An update bot that bumps such a dependency without a license check changes your obligations in a routine pull request.

Write a license policy by distribution model
License groupInternal toolHosted service (SaaS)Distributed binary, app or firmware
Permissive (MIT, BSD, Apache-2.0, ISC)AllowAllowAllow; ship notices
Weak copyleft (MPL-2.0, LGPL, EPL-2.0)AllowAllowReview: source of the covered files or library, relinking for LGPL
Strong copyleft (GPL)AllowAllow if not distributedDeny unless the product is meant to be GPL or the use is separate (for example a separate program)
Network copyleft (AGPL-3.0)ReviewReview: modified versions must offer source to usersDeny unless approved
Source-available (SSPL, BUSL, Elastic-2.0)ReviewReview against the use restrictionsReview against the use restrictions
Unknown, NOASSERTION, LicenseRef-ReviewReviewReview

Enforce the policy from the SBOM

Gate releases on the SBOM of the release, because that is the list your notices and your customers' tools will use. The script below reads a CycloneDX JSON SBOM, evaluates each component's license entries as an SPDX expression (a choice under OR takes the best option, AND takes the worst, and a WITH exception needs its own policy entry), and reports everything that is not allowed. It exits non-zero on any denied license, so it can fail a release job. Several license entries on one component are treated as all applying, the conservative reading, because CycloneDX does not define how they combine.

Trivy can do a similar check from lockfiles or images with --scanners license, sorting licenses into forbidden, restricted, reciprocal, notice, permissive and unencumbered categories that you can edit in trivy.yaml. For deeper analysis of source files, ScanCode Toolkit and the OSS Review Toolkit detect license texts that metadata does not declare.

Testlicense_gate.pyPython
"""Check the licenses in a CycloneDX JSON SBOM against an allow/review/deny policy.

Usage: python3 license_gate.py sbom.cdx.json   (exit 1 if anything is denied)
"""
import json
import re
import sys

# Example policy for software distributed to customers as a binary; adjust per product.
ALLOW = {"MIT", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "ISC", "0BSD", "Zlib", "Unlicense"}
REVIEW = {"MPL-2.0", "LGPL-2.1-only", "LGPL-2.1-or-later", "LGPL-3.0-only", "LGPL-3.0-or-later", "EPL-2.0",
          "GPL-2.0-only WITH Classpath-exception-2.0"}
DENY = {"GPL-2.0-only", "GPL-2.0-or-later", "GPL-3.0-only", "GPL-3.0-or-later",
        "AGPL-3.0-only", "AGPL-3.0-or-later", "SSPL-1.0", "BUSL-1.1", "Elastic-2.0"}
RANK = {"allow": 0, "review": 1, "deny": 2}


def classify_id(license_id):
    """Outcome for one SPDX identifier; anything unknown, LicenseRef or NOASSERTION is review."""
    upper = license_id.upper()
    for outcome, ids in (("allow", ALLOW), ("review", REVIEW), ("deny", DENY)):
        if upper in {i.upper() for i in ids}:  # SPDX identifiers are case-insensitive
            return outcome
    return "review"


def evaluate(tokens):
    """SPDX expression: WITH binds tightest, then AND, then OR (a choice: take the best)."""
    def term():
        token = tokens.pop(0)
        if token == "(":
            outcome = disjunction()
            tokens.pop(0)  # ")"
            return outcome
        if tokens and tokens[0].upper() == "WITH":
            tokens.pop(0)
            # An exception adds permissions, so the pair needs its own policy entry.
            return classify_id(f"{token} WITH {tokens.pop(0)}")
        return classify_id(token)

    def conjunction():
        outcome = term()
        while tokens and tokens[0].upper() == "AND":
            tokens.pop(0)
            outcome = max(outcome, term(), key=RANK.get)
        return outcome

    def disjunction():
        outcome = conjunction()
        while tokens and tokens[0].upper() == "OR":
            tokens.pop(0)
            outcome = min(outcome, conjunction(), key=RANK.get)
        return outcome

    return disjunction()


def component_outcome(component):
    choices = component.get("licenses") or []
    if not choices:
        return "review", "no license data"
    expressions = []
    for choice in choices:
        if "expression" in choice:
            expressions.append(choice["expression"])
        elif "id" in choice.get("license", {}):
            expressions.append(choice["license"]["id"])
        else:  # a named, non-SPDX license
            return "review", "named license: " + choice.get("license", {}).get("name", "?")
    # CycloneDX does not define how several entries combine; AND is the conservative reading.
    expression = " AND ".join(f"({e})" for e in expressions)
    return evaluate(re.findall(r"\(|\)|[^\s()]+", expression)), expression


with open(sys.argv[1], encoding="utf-8") as f:
    bom = json.load(f)
worst = "allow"
for component in bom.get("components", []):
    outcome, detail = component_outcome(component)
    worst = max(worst, outcome, key=RANK.get)
    if outcome != "allow":
        print(f"{outcome.upper():7} {component.get('purl') or component.get('name')}  {detail}")
sys.exit(1 if worst == "deny" else 0)

Catch new licenses in pull requests

Checking at release time finds problems late. GitHub's dependency review action compares the dependency graph of a pull request with its base and fails when a new dependency's license is not on the allow-list. Use allow-licenses rather than deny-licenses, which the action has deprecated: an allow-list also stops licenses nobody has reviewed yet. The commands after the workflow run the same kind of check outside GitHub.

Configuration.github/workflows/dependency-review.ymlYAML
# .github/workflows/dependency-review.yml
name: dependency-review
on: pull_request
permissions:
  contents: read
jobs:
  licenses:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
        with:
          # An allow-list fails the PR on any license not listed, including unknown ones
          allow-licenses: MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, 0BSD

Run the checks from the command line

Generate the SBOM with the same tool and options you use for the release, so the license gate and the published SBOM agree. /domains/supply-chain/sbom/formats-and-identifiers/ explains the CycloneDX and SPDX fields these commands read.

license-checks.shShell
# Generate the SBOM from what you ship, then gate on it
syft dir:. -o cyclonedx-json=sbom.cdx.json
python3 license_gate.py sbom.cdx.json

# Or let Trivy classify licenses and fail on forbidden (CRITICAL) and restricted (HIGH)
trivy fs --scanners license --severity CRITICAL,HIGH --exit-code 1 .

# Rust: cargo-deny reads the [licenses] section of deny.toml
cargo deny check licenses

Produce the compliance artifacts with each release

  • A third-party notices file generated from the release SBOM, with each component's name, version, copyright lines and full license text, shipped inside the product or its documentation.
  • The contents of every Apache-2.0 NOTICE file you distribute, carried into your notices.
  • For GPL and LGPL components in distributed products, the corresponding source, or a written offer to provide it, for the exact versions you shipped; keep the source archived per release.
  • For AGPL components you modified and run as a service, a way for users to get the source of the running version.
  • For your own repositories, REUSE 3.3 headers (SPDX-License-Identifier, SPDX-FileCopyrightText) and license texts under LICENSES/, so your customers' tools can read your licensing.
  • A record of each review decision and exception; OpenChain ISO/IEC 5230 describes the program around these artifacts if you need to show a process to customers.

Guided practice

Your team ships Ledger in two ways: as a hosted SaaS and as an on-premises installer that customers run on their own servers. Apply the policy in this guide to the SBOM excerpt and decide what to do for each component and each delivery model.

Supplied scenario material
Plain text
C1 pkg:npm/chart-kit@5.2.0  license: AGPL-3.0-only (declared); Ledger patches it.
C2 pkg:pypi/fastparse@1.4.0  license: LGPL-3.0-or-later; dynamically imported, not modified.
C3 pkg:maven/org.example/http-core@2.1.0  license: Apache-2.0; ships a NOTICE file; the on-prem installer contains no NOTICE text.
C4 pkg:npm/tiny-uuid@0.3.1  license: NOASSERTION; the repository has no LICENSE file.
C5 pkg:golang/github.com/example/cache@v1.9.0  license: MPL-2.0 (declared); Renovate proposes v2.0.0, whose LICENSE file is BUSL-1.1 with an Additional Use Grant that excludes offering it as a hosted service.
C6 pkg:cargo/fast-hash@0.8.0  license: MIT OR Apache-2.0.
  1. 1. Decide C1

    Apply the policy to the AGPL component for both delivery models.

    Expected result and reasoning

    SaaS: review, and because Ledger modifies it and users interact with it over the network, Ledger must offer those users the source of the modified chart-kit; either do that or replace it. On-premises: deny unless legal approves, because distributing the modified program brings full AGPL source obligations to customers.

  2. 2. Decide C2 and C6

    Classify the LGPL library and the dual-licensed crate.

    Expected result and reasoning

    C2: allowed for SaaS; for the installer it is a review case that normally passes, because the library is unmodified and replaceable; ship its license text and source offer. C6: allowed in both; record the choice (for example MIT) and ship the notice.

  3. 3. Fix C3

    What is missing for the on-premises installer?

    Expected result and reasoning

    Apache-2.0 requires passing on the NOTICE file contents with a distribution. Add them to the installer's third-party notices; the SaaS does not distribute the component, so nothing changes there.

  4. 4. Handle C4

    Decide what NOASSERTION means here.

    Expected result and reasoning

    There is no license grant, so the component cannot be shipped in either model until the author adds a license or it is replaced. Open an issue upstream and plan a replacement; do not record it as permissive.

  5. 5. Stop C5's upgrade

    Decide on the Renovate pull request.

    Expected result and reasoning

    Block it: v2.0.0 changes the license from MPL-2.0 to BUSL-1.1, whose grant excludes offering it as a hosted service, which is how Ledger SaaS uses it. Stay on v1.9.0 under MPL-2.0 (keeping its source available for the installer), evaluate a fork or alternative, and add a license check to the update bot's pull requests.

Your deliverable: A table of the six components with the outcome for SaaS and for the on-premises installer, the obligation to meet, and the owner of each follow-up.

The components and license facts are invented, and the outcomes follow this guide's example policy, not legal advice. Real decisions depend on the exact license text, how the code is combined, and counsel's reading for your products.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. SPDX specification 3.0.1: SPDX License Expressions annex
  2. SPDX License List. Version 3.29.0 (16 September 2026).
  3. spdx/license-list-data release v3.29.0. 16 September 2026.
  4. CycloneDX 1.7 JSON reference: licenses
  5. Open Source Initiative: The SSPL is not an open source license. 19 January 2021.
  6. Trivy: license scanning
  7. actions/dependency-review-action: README
  8. REUSE Specification 3.3
  9. HashiCorp adopts the Business Source License (2023-08-10)
  10. Redis is now available under the AGPLv3 open source license (2025-05-01)
  11. Elastic: Elasticsearch is open source, again (2024-08-29)
  12. GNU Affero General Public License v3.0, section 13. Version 3, 19 November 2007.

Reference information

Download this guide as Markdown