Dependencies

License compliance

Classify each dependency's license and check it against a deployment-aware, tri-state policy — offline, with a CI exit-code gate and reachability-based re-ranking.

License compliance

xgrep deps license evaluates the license of every dependency against a local, offline compliance policy. For each package it:

  1. reads two signals — the license declared in a manifest or lockfile, and the license concluded from the tree itself (see Two license signals),
  2. normalizes them to SPDX identifiers, parsing expressions like MIT OR Apache-2.0,
  3. classifies the result into a category (permissive → copyleft → network-copyleft → commercial), and
  4. returns a tri-state Approve / Flag / Deny verdict — but only gates when you ask it to (see Gating is opt-in).

Everything runs offline — file parsing plus a code-graph query, no network. The full SPDX license list is embedded in the binary, so every published identifier is recognized, including deprecated spellings like GPL-2.0 (normalized to GPL-2.0-only). Normalization applies to what xgrep reports, not just to what it gates on: a license named in a report, an SBOM and a gate message is the same current identifier every time, so GPL-2.0 never reaches a compliance document still stating neither -only nor -or-later.

xgrep deps license .                                 # report every license (no gating)
xgrep deps license --allow-licenses MIT,Apache-2.0 . # fail on anything outside the allowlist
xgrep deps license --disallow-licenses GPL-3.0-only .# or fail only on named licenses
xgrep deps license --context saas-hosted .           # or gate by deployment-context preset
xgrep deps license --json .                          # machine-readable report

Gating is opt-in

By default xgrep deps license reports every dependency's license and gates on nothing — it never turns a license into a finding on its own. You choose how (and whether) to gate:

  • An allowlist--allow-licenses MIT,Apache-2.0 names the SPDX licenses you permit. Any dependency whose license is not on the list is a Deny finding; a license you cannot identify is flagged for review. A dual license satisfies the list if either side is allowed (MIT OR GPL-3.0-only passes an allowlist containing MIT); a combined A AND B needs both.

  • A denylist--disallow-licenses GPL-3.0-only,LGPL-3.0-only is the complement: only the named licenses are a Deny finding, and everything else — including a license you cannot identify — is allowed. Use it for "everything is fine except these". A dual license is denied only when both sides are (MIT OR GPL-3.0-only is not denied — the consumer can take MIT); a combined A AND B is denied if either side is. The two are mutually exclusive.

    Both are the objective gate: they assert nothing about what a license means, only whether it is one you have decided to accept or refuse, so they need no legal interpretation.

  • A deployment context--context saas-hosted gates by the category presets below (what a license obligates, given how you ship). These presets are practical scaffolding, not legal advice.

  • A policy file--policy license-policy.yaml for a persistent, auditable version of either of the above (an allow: list, or context + per-category and per-license rules).

The exported SBOM (xgrep sbom) always carries the licenses regardless of any gate — the allowlist governs findings, not what is reported.

Deployment context decides the obligation

The same license carries different obligations depending on how you ship, so policy is anchored by a deployment context. AGPL is fatal for a hosted service but irrelevant to a tool you never distribute; GPL matters for a binary you ship but not for an internal script. Choose the context that matches your product:

ContextUse whenDisposition highlights
distributed-binary (default)You ship a binary or bundle to othersStrong copyleft (GPL) denied, weak copyleft flagged
saas-hostedServer-side / SaaS, not distributedNetwork copyleft (AGPL/SSPL) denied, plain GPL flagged, weak copyleft allowed
internal-toolNever leaves your organizationAlmost every OSS license allowed; only commercial is flagged
libraryYou distribute a library others embedStrictest: strong copyleft and commercial denied, weak copyleft flagged

The presets are practical scaffolding, not legal advice — review them with your own counsel and override as needed.

The three verdicts

  • Deny — a policy violation. Counts toward the CI gate (exit code 1).
  • Flag — a review item. Reported, but does not fail the gate by default.
  • Approve — clean.

An undeclared or unrecognized license is always at least flagged — it is surfaced for review, never assumed to be clean.

Reachability re-ranks denials

A dependency present in a lockfile is not the same as one your code actually uses. When xgrep can build the code graph, a denied license on a package that no first-party code imports is demoted to a Flag (a review item) rather than failing the build — re-ranked by reachability, never suppressed. Disable this with --no-reachability (which treats every package as reachable). It is skipped automatically with --ref, since the graph builds from the working tree.

What you owe, not just what is allowed

A verdict says whether a license is acceptable. --obligations says what it requires of you:

$ xgrep deps license --obligations .

  github.com/lgpl/lib@v1.0.0 — LGPL-2.1-only
      • license-text-inclusion: Ship a copy of the license text with the distributed artifact.
      • notice-preservation: Keep the copyright and license notices in any copies you distribute.
      ? relink-permitted: Static linking obliges you to let users relink against a modified
        library — ship object files or an equivalent mechanism. Dynamic linking carries no such duty.
        ↳ uncertain: linkage has not been determined — treat as static until it is

Duties are anchored by the same deployment context as the verdict, because the context decides which triggers fire. AGPL owes a network source offer under saas-hosted and does not under internal-tool; a distribution duty is the other way round.

Linkage and modification decide several duties

Two facts about how you use a dependency settle obligations that a manifest cannot answer:

Is it statically linked? LGPL's substantive duty — let users relink against a modified library — applies to static linking and not to dynamic. xgrep infers this from the build: Go and Rust link into the binary; a JavaScript or Python project loads dependencies at run time unless a bundler config (webpack, Vite, Rollup, Next.js, PyInstaller, …) is present, which flips it; a Java project is classpath-loaded unless a shading or fat-jar plugin is configured.

Were its files modified? MPL, EPL and CDDL scope their disclosure to the licensed files you changed, so an unmodified dependency owes nothing there. A local patch in a vendored copy, or a patches/ entry naming the package, is evidence that it was.

  github.com/lgpl/lib@v1.0.0 — LGPL-2.1-only
      • relink-permitted: Static linking obliges you to let users relink...

A duty marked ? is one where the determination could not be made. It is reported as uncertain, never as satisfied:

  • Absence of a bundler config means the check found nothing, not that the project does not bundle — so it never weakens a static answer.
  • Finding no patch does not prove a vendored copy is pristine, so a clean tree reports undetermined rather than "unmodified".

Assuming "dynamically linked" or "unmodified" would silently discharge LGPL's and MPL's only substantive obligations, which is the one failure mode this design exists to avoid.

Source disclosure carries its scope, which is the difference between publishing a patched file and publishing your product:

ScopeReaches
fileOnly the licensed files you modified (MPL, CDDL)
libraryThe library itself, not your application (LGPL)
derivative-workThe whole derived work (GPL)
networkThe whole derived work, to network users (AGPL)

The obligation tables are engineering scaffolding, not legal advice. They exist to give you a concrete checklist to take to counsel.

Use xgrep deps notice to produce the attribution document that discharges the notice, license-text and attribution duties.

Can it coexist with your own license?

Policy asks do we allow this license. Compatibility asks a different question: can this dependency and our own license coexist in one distributed work. A project can allow GPL-2.0-only by policy and still be unable to ship it inside an Apache-2.0 product.

$ xgrep deps license --compatibility .

  Outbound: Apache-2.0  (from root-license-file: LICENSE)

  incompatible                 GPL-3.0-only   github.com/gpl/tool@v1.0.0
        ↳ "GPL-3.0-only" requires the whole derived work to be licensed under its terms,
          which a "Apache-2.0" work does not do. Either relicense, replace the dependency,
          or isolate it behind a process boundary
  compatible-with-obligations  MPL-2.0        github.com/mpl/thing@v2.0.0
        ↳ "MPL-2.0" is file- or library-scoped: it can sit inside a "Apache-2.0" work,
          but changes you make to the licensed code must be published

Your outbound license — what your project itself is licensed under — is resolved in this order: the --outbound flag, the policy's outbound: setting, the root LICENSE file's SPDX identifier, the text of that file matched against the built-in SPDX license corpus, the root manifest's license field (package.json, Cargo.toml, pyproject.toml, composer.json), then the majority of your source file headers. If none of those determine it, every pair is reported as review rather than judged against a guess.

Most projects paste the license text without adding an SPDX-License-Identifier line, which is why the text is read as well as the tag. That step reports root-license-text as its source, so you can tell a licence xgrep identified from one your file stated. It concludes only where the root says one thing unambiguously: licenses whose texts SPDX publishes identically (the GPL family among them), and projects offering a choice across two files such as LICENSE-APACHE and LICENSE-MIT, fall through to the manifest instead, since that is where a disjunction like MIT OR Apache-2.0 can actually be stated.

Where your source headers disagree with your stated license, that is called out too — it is a real inconsistency that only shows up when something goes looking.

Only settled pairs get a verdict

incompatible is reserved for readings that are not in dispute: strong or network copyleft inside a non-copyleft work you distribute, and GPL-2.0-only inside Apache-2.0 (the patent termination clause). Everything else is compatible, compatible-with-obligations, or review.

That is deliberate. A wrong "incompatible" is the kind of false positive that gets a scanner switched off, and license compatibility is an area where confident wrong answers are easy to produce. Two consequences worth knowing:

  • A dual-licensed dependency (MIT OR GPL-3.0-only) is never judged on its most restrictive side — you may pick the other one. Pin your choice with a choices entry and re-run.
  • Deployment context changes the answer. AGPL inside a proprietary binary is incompatible; inside a hosted service it is compatible with a live obligation to offer source to network users. Same pair, different verdict.

Writing a policy

Without a policy file, deps license uses the built-in preset for the chosen context. To customize, create .xgrep/license-policy.yaml (auto-discovered) or pass --policy <file>:

license:
  context: saas-hosted # overridden by --context if set
  presets: true # start from the context preset, then override below
  fail-on: deny # gate threshold: deny (default) or flag
  prefer:
    concluded # when declared and concluded disagree; unset =
    # evaluate the more restrictive of the two
  outbound: Apache-2.0 # your project's own license, for --compatibility

  # Rules take a category (e.g. network-copyleft) or an SPDX ID (e.g. AGPL-3.0-only).
  # An ID rule overrides the category disposition. An ID rule also applies to an
  # operand of an AND expression (both sides are binding), but not inside an OR,
  # where the consumer may choose the other license — use `choices` to pin those.
  deny:
    - network-copyleft
    - LicenseRef-Internal-Banned
  flag:
    - strong-copyleft
  approve:
    - weak-copyleft

  # Resolve a dual license to the operand you rely on.
  choices:
    'MIT OR GPL-3.0-only': MIT

  # Override a wrong or missing declared license for a specific package.
  curations:
    - purl: 'pkg:npm/mystery@1.0.0'
      concluded: 'MIT'

  # Skip packages entirely (glob on purl).
  ignore:
    - 'pkg:npm/internal-*@*'

  # Approved, time-boxed exceptions. Prefer these to `ignore` — see below.
  waivers:
    - purl: 'pkg:npm/sneaky@*'
      reason: 'isolated behind a process boundary; source offer served separately'
      owner: 'legal@example.com'
      expires: '2026-09-05'
    - license: 'GPL-3.0-only'
      reason: 'build-time only, never shipped'
      owner: 'eng-lead@example.com'
      expires: '2026-12-31'

What could not be settled is a finding

A licence report is only useful if you can tell which of its answers to rely on. Four things can be unsettled about a package, and each is reported as its own finding — so it can be tracked, triaged and waived like any other, rather than being a silence with nowhere to record a decision.

findingwhat it meanswhat to do
undeterminedno licence evidence exists at allfind out; the package is never assumed clean
unresolveda licence file was read and could not be identifiedread it — the finding names what it came closest to
ambiguousthe text matches several identifiers equallyread the licensed file's own header, which is where the distinction lives
choice-unpinnedthe licence offers a choice and none is pinnedrecord which side you rely on with choices
FLAG    unknown  (unresolved)  vendored-fork@1.0.0
        ↳ a license file was read at node_modules/vendored-fork/LICENSE but not
          identified — closest: MIT (70%), JSON (69%), X11-swapped (67%).
          Review it, then record the answer as a curation or a waiver

(unresolved) and (undetermined) are different facts, and only the first comes with somewhere to look.

An ambiguity is a finding whatever the licence. SPDX publishes identifiers whose licence texts are byte identical, so the text alone cannot say which of them a file is. Every candidate is reported, and the verdict is computed on the most restrictive of them — the text cannot choose, and erring toward asking is the only defensible default.

An unpinned choice is reported only when nothing else resolved it. If an allow-list approves MIT OR GPL-3.0-only because MIT satisfies it, you have already recorded which side you rely on; you will not be told to pin a choice you have pinned.

Each of these is waivable exactly like a denied licence — by purl, with an owner, a reason and an expiry:

waivers:
  - purl: '*vendored-fork*'
    reason: 'vendored fork; licence confirmed MIT by legal 2026-08'
    owner: 'compliance@example.com'
    expires: '2027-01-31'
APPROVE unknown  (unresolved)  vendored-fork@1.0.0  [waived]
        ↳ waived by compliance@example.com until 2027-01-31: vendored fork;
          licence confirmed MIT by legal 2026-08

Note that an unresolved package has no licence identifier, so a license: waiver cannot match it — waive it by purl. That is deliberate: a blanket exception for everything the tool could not read is an ignore with extra steps, which is what waivers exist to replace.

Waivers instead of ignores

ignore removes a package from the report permanently and anonymously. A year later nobody knows who decided that, why, or whether it still holds.

A waiver records the decision and expires:

  APPROVE network-copyleft AGPL-3.0-only  sneaky@1.0.0  [mismatch]  [waived]
          ↳ waived by legal@example.com until 2026-09-05: isolated behind a
            process boundary; source offer served separately

reason, owner and expires are all required — they are the entire difference between a waiver and an ignore, so a waiver without them would just be an ignore that looks accountable. A malformed one fails the policy load rather than sitting silently inert.

Match by purl (with * wildcards), by license to cover a whole class at once, or by both to narrow to their conjunction.

An expired waiver brings the finding back

This is the property that makes waivers safe to grant:

  FLAG    strong-copyleft  GPL-3.0-only  @babel/core@7.24.0  [waiver expired]
          ↳ ... (waiver by eng-lead@example.com expired on 2026-01-01 — the
            finding stands; renew it or fix the dependency)

The verdict is untouched and the gate fails again. To renew, add a new waiver with a later date — the active one wins, so the lapsed entry can stay as a record of the original decision.

Waivers lapsing within 30 days are announced at the top of the report, so a team can renew or replace the dependency before the build starts failing:

Waivers expiring within 30 days:
  pkg:npm/sneaky@* expires 2026-09-05 (legal@example.com) — isolated behind a process boundary

The underlying verdict is always preserved in raw_verdict, so a waived finding is suppressed in the gate but never erased from the record.

In CI

deps license exits 1 when any package meets the --fail-on threshold (deny by default, or flag to also fail on review items), so it drops into a pipeline as a gate:

xgrep deps license --context saas-hosted --fail-on deny .

The exit code is the same in every output format, so --format json and --format sarif gate exactly as the table does.

On a pull request: gate only on what the change introduces

Gating a branch on every license finding fails it for an inherited backlog nobody on that branch created — and a check that always fails gets switched off. --since makes the gate diff-aware:

xgrep deps license --since origin/main --context saas-hosted .
License changes since origin/main
(comparing the same signal on both sides: concluded where origin/main committed the source to conclude from, declared otherwise)

  ! added     DENY    AGPL-3.0-only   newly-added@1.0.0

  1 pre-existing finding(s) not introduced by this change — reported, not gated:
    DENY    AGPL-3.0-only            sneaky@1.0.0
  Run without --since to gate on all of them.

Only the ! rows fail the build. Pre-existing findings are still reported — hiding them would make the backlog invisible rather than deferred — they just do not fail a branch that did not cause them.

A version bump that keeps the same license is not a licensing change and is not reported as one. A package that gains a declared license where it had none is, even though the dependency is not new.

What is compared. Always the same signal on both sides, decided per package: the concluded license against the concluded license where the ref committed the source to read it from, and the declared license against the declared one otherwise. Mixing the two is what makes a diff gate useless — comparing a ref's declared license against your working tree's reconciled verdict marks every package that gained a concluded license as changed, which is most of them, and buries the one your change actually touched.

That is also what lets the gate catch the case it most needs to: a dependency whose shipped license changed while its declared license did not — a version bump where the vendor swapped the LICENSE file. Where a repository does not commit its dependencies there is nothing at the ref to conclude from, and those packages are compared on their declared licenses, as before.

SARIF for code scanning

--format sarif emits SARIF 2.1.0 for GitHub Code Scanning and any other SARIF consumer:

xgrep deps license --format sarif . > license.sarif

Findings get their own rule namespace — xgrep/license/denied, .../review, .../undetermined, .../mismatch, .../waiver-expired — so a reviewer can tell a license question apart from a security one without reading the message. They carry no security-severity: these are compliance findings, and scoring them against CVEs in the same list would be meaningless.

Each result points at the most specific evidence available (the license file a conclusion was read from, else the manifest that declares the dependency) and is fingerprinted on the package rather than a line number, so an alert tracks the dependency across lockfile churn.

As part of xgrep ci

xgrep ci --license runs the license gate after the scan, in its own lane:

xgrep ci --license --license-full --license-context saas-hosted .

License findings are reported under their own heading and never enter the scan report or the vulnerability count. A denied license is a governance decision, not an exploitable bug — folding the two together would let a compliance question inflate a security number and put it in front of the wrong reviewer. Either gate failing fails the build, which is what a gate is for.

FlagDescription
--licenseEnable the license gate (off by default).
--license-fullAlso identify licenses by matching license-file text.
--license-contextDeployment context preset.
--license-fail-onGate threshold: deny or flag.
--license-formatReport format: table, json, sarif, or gitlab.

GitLab license scanning

--format gitlab writes the report GitLab reads for its merge-request widget and project license list:

xgrep deps license --format gitlab . > gl-license-scanning-report.json

Two things are worth knowing about the shape GitLab requires. A dependency carries a list of license IDs and there is no room for an expression, so MIT OR Apache-2.0 is decomposed into its operands: correct for AND, but the choice in an OR is lost. Pin it with a policy choices: entry before exporting if that distinction matters to you.

And every package appears, including the ones whose license could not be determined — they go in under an unknown ID rather than being left out. Omitting them would make the report say there is nothing there, which is a stronger claim than xgrep can make.

The report itself carries no verdict; GitLab applies its own policy on top. The exit code still comes from xgrep's gate, so this format fails a build exactly as the others do.

Checking an SBOM you were given

--sbom evaluates the policy over a document instead of a source tree — a vendor's CycloneDX, a build system's SPDX, or an earlier xgrep sbom run:

xgrep deps license --sbom vendor-bom.cdx.json --allow-licenses MIT,Apache-2.0

CycloneDX (JSON and XML), SPDX (JSON and tag-value) and xgrep's native format are all recognized without naming the format.

Read the report knowing what a document cannot contain. There is no first-party code, so nothing can be shown unreachable and nothing is demoted. There is no tree to read a license out of, so a concluded license survives only where the producer recorded one — CycloneDX evidence is that field, and xgrep fills it, so a document xgrep produced keeps the declared-vs-concluded split intact. A license the producer missed is a license this report misses too.

A run over an SBOM that yields fewer findings than a scan of the same project is a thinner input, not a cleaner project. The report says so in its own header rather than leaving you to remember it.

Flags that need the tree are refused rather than ignored: --ref and --since have no repository to read, and --compatibility needs your own outbound license, which is detected from source.

Asking from an agent

The MCP server exposes license_check, which runs the same pipeline against the same policy rules, so an agent and a person cannot get different answers about one tree. It takes a path plus the same context, policy and allow/deny options, and returns a verdict per package.

Two differences from the command line. Reachability is off unless asked for, because building the code graph is the slow part of a check and an agent call is interactive — with it off nothing is demoted, so the tool over-reports rather than under-reports. And the response carries whether detection and reachability actually ran, because a short list of findings means either "nothing to find" or "could not look", and those need telling apart.

Where a declared license comes from

Every package in the report says where its license came from, or — when there is none — why there is none. The distinction matters: a package with no license in a lockfile that has the field is a gap in that dependency, while a Maven package with no license is a gap in coverage, because a pom.xml was never able to state one.

{
  "name": "org.apache.logging.log4j:log4j-core",
  "declared_license": "Apache License, Version 2.0",
  "normalized_license": "Apache-2.0",
  "declared_source": "local-repo",
  "declared_from": "/home/you/.m2/repository/org/apache/apache/23/apache-23.pom"
}
declared_sourceMeaning
manifestThe dependency file stated it.
lockfileRecovered from a lockfile field.
metadataRead from installed package metadata — a Python dist-info, a .nuspec, a jar manifest.
local-repoRead from a local package repository or environment outside your tree — ~/.m2/repository, the Gradle cache, the NuGet package folder, an installed Python environment.
registryFetched over the network, which only happens with --resolve-licenses.
noneThe dependency file carries a license field and this package left it empty.
unavailableThe dependency file has no license field at all, and no other source supplied one.

metadata and local-repo are answers that come from build state rather than from your repository: the same tree on a machine that has never built the project reports differently. declared_from is the file each was read from, so you can check it. Use --no-local-repos for a run that must rest only on what the repository itself states.

The report also tallies this per ecosystem, which is what makes a screen of unknown readable:

$ xgrep deps license apps/java-shop
note: 9 of 9 packages have no license from any signal
note: their dependency files have no per-dependency license field, so these were
      never stated rather than left blank: java (9 of 9)

Java is the case where reading the local package cache changes the answer most. A dependency's license usually lives not in its own POM but in a parent it inherits from, sometimes several levels up, and xgrep walks that chain:

$ xgrep deps license apps/java-shop     # after `mvn package` has run
  APPROVE notice        Apache-2.0   org.apache.logging.log4j:log4j-core@2.14.1
  APPROVE notice        Apache-2.0   org.springframework:spring-core@5.3.18
  APPROVE weak-copyleft EPL-1.0      junit:junit@4.13.2

Gradle works the same way, from a different cache. A gradle.lockfile states coordinates and resolved versions and, like a pom.xml, nothing about its dependencies' licenses. Gradle does not populate ~/.m2 — it resolves into its own cache under GRADLE_USER_HOME (~/.gradle by default) — so xgrep reads the POMs from there too, walking the same parent chains. A machine that has run both build tools has its chain crossed between the two caches, and that resolves as well: the parent is looked for wherever it actually is.

.NET is the same shape. Neither manifest can carry a license: a packages.config entry has only an id, a version and a target framework, and a packages.lock.json entry carries the resolved version and a content hash. The license is in each package's own .nuspec, as an SPDX expression. xgrep reads it from packages/<id>.<version>/ when a legacy restore left it in your tree, and otherwise from the NuGet package folder dotnet restore writes — $NUGET_PACKAGES, or ~/.nuget/packages. A PackageReference project puts nothing in the tree at all, so on a modern project the folder is the only place the answer exists:

$ xgrep deps license apps/csharp-shop     # after `dotnet restore` has run
  APPROVE notice  MIT           Newtonsoft.Json@13.0.3
  APPROVE notice  Apache-2.0    Serilog@3.1.1
  APPROVE notice  BSD-3-Clause  Moq@4.20.70

A package is looked up by the exact id and version your project resolved, so a different version of the same package sitting in the folder is never used to answer for it — packages do change license between releases.

Some packages name a license file rather than stating an identifier. The file ships inside the package, so --license-full identifies it against the embedded SPDX corpus, the same way that flag identifies the license files of vendored dependencies. Without it those packages stay undetermined rather than guessed at:

$ xgrep deps license apps/csharp-shop
  FLAG    unknown  (undetermined)  NUnit@3.13.3

$ xgrep deps license --license-full apps/csharp-shop
  APPROVE notice   MIT             NUnit@3.13.3

Python is the same shape again. None of the five Python dependency formats carries a license — a requirements.txt line is name==version, and Pipfile.lock, poetry.lock, uv.lock and pdm.lock record hashes and resolved versions but no license. Every one of them resolves the same way, because xgrep reads the installed distribution rather than the manifest: the METADATA in a .dist-info directory, or the PKG-INFO of the older .egg-info layout.

It looks for that in your tree first — a .venv/ beside the project is the common case — and then in the environment your tooling points at, which for most layouts is where the packages actually are:

WhereFound via
An activated virtualenv, anywhere on diskVIRTUAL_ENV
A poetry, pipenv, hatch or uv environmentVIRTUAL_ENV, once activated
A conda environmentCONDA_PREFIX
pip install --userthe per-user site directory under your home
$ xgrep deps license apps/python-shop      # nothing installed
note: 6 of 6 packages have no license from any signal

$ source .venv/bin/activate                # or any of the above
$ xgrep deps license apps/python-shop
  APPROVE notice        BSD-3-Clause  click@8.1.7
  APPROVE notice        Apache-2.0    requests@2.31.0
  APPROVE weak-copyleft MPL-2.0       certifi@2023.11.17

Three metadata forms are read, in order: License-Expression (an SPDX expression, and the form modern build backends emit), the older free-text License, and the License :: OSI Approved :: … trove classifiers. A distribution listing several classifiers is offering a choice, so they are joined as one. Names are matched case- and separator-insensitively, so python_dotenv on disk answers for python-dotenv in your manifest.

A distribution answers only for the version it says it is. If your manifest pins 2.0.0 and the environment has 1.0.0 installed, the package is reported as undetermined rather than given the older release's license — packages do relicense between versions, and a wrong license in a compliance report is worse than a missing one. Versions are compared the way Python compares them, so a pinned 1.0 and an installed 1.0.0 are the same release.

When nothing is installed at all

Every source above reads something a build or an install already put on disk, so a machine that has never built or installed the project has nothing to read — a CI runner scanning a fresh checkout, or anyone reading someone else's repository. --resolve-licenses covers that case by asking the package's own service: Maven Central for Java, and the Python package index for Python.

$ xgrep deps license apps/python-shop        # nothing installed
note: 6 of 6 packages have no license from any signal

$ xgrep deps license --resolve-licenses apps/python-shop
  APPROVE notice        BSD-3-Clause  click@8.1.7          [registry]
  APPROVE notice        Apache-2.0    requests@2.31.0      [registry]
  APPROVE weak-copyleft MPL-2.0       certifi@2023.11.17   [registry]

It is off by default, and every other source is offline, so a normal run never reaches the network. Answers it produces are marked declared_source: registry, and it runs last — it can fill a blank no offline source could, and never replaces an answer one already gave.

Each release is asked for by the exact version your manifest pinned. A version the index does not have is reported as undetermined rather than answered with whatever the latest release says, for the same reason an installed distribution only answers for its own version.

Point either service elsewhere with --resolve-repo (a Maven repository) and --resolve-index (a Python package index) — an internal mirror, say. Both accept http and https only.

Two license signals

A dependency can tell you its license in two places, and they do not always agree.

Declared is what a package's own metadata says. It is cheap to read but uneven, because not every dependency file has somewhere to put it. An npm or Composer lockfile records a license per package, so reading the lockfile answers the question outright. A pom.xml cannot: its <licenses> element describes the project the file belongs to, never its dependencies. A requirements.txt and an SDK-style .csproj carry no license field at all.

Where the dependency file cannot answer, xgrep reads the package metadata a package manager has already written to disk — an installed Python distribution's METADATA, the POMs in your local Maven or Gradle cache, a restored .nuspec. This runs no package manager and opens no network connection; it reads files that are already there. See Where a declared license comes from below.

Concluded is what the shipped code says — an SPDX-License-Identifier header in a source file, or the LICENSE / COPYING / NOTICE file that comes with a vendored dependency. Where the source is in your tree (vendor/, node_modules/, and nested vendoring inside them), this is often the only signal there is:

$ xgrep deps license --no-detect .        # declared only
  FLAG    unknown  (undetermined)  github.com/foo/bar@v1.2.3
  FLAG    unknown  (undetermined)  gopkg.in/yaml.v3@v3.0.1

$ xgrep deps license .                    # both signals
  APPROVE notice   MIT             github.com/foo/bar@v1.2.3   [concluded]
  APPROVE notice   Apache-2.0      gopkg.in/yaml.v3@v3.0.1     [concluded]

By default, detection concludes only from an explicit SPDX identifier. Most license files do not carry one — they carry the license prose — which is what --license-full reads:

$ xgrep deps license .
  FLAG    unknown  (undetermined)  lodash@4.17.21

$ xgrep deps license --license-full .
  APPROVE notice   MIT             lodash@4.17.21  [concluded]

Every SPDX license text ships inside the binary, so this stays offline. The text is matched by similarity and the result carries a confidence, which is reported alongside the license:

"concluded_license": "MIT",
"concluded_from": "node_modules/lodash/LICENSE (text match, 100% confidence)"

A text below the confidence threshold concludes nothing. The license file stays recorded as evidence and the package is reported as undetermined, rather than having the nearest-looking license attached to it. That is the point of scoring the match instead of just answering.

Some licenses cannot be told apart from their text at all. SPDX publishes 59 identifiers whose texts are byte-identical — GPL-2.0-only and GPL-2.0-or-later among them, because the distinction is stated in the licensed file's header rather than in the license. Those are reported together rather than resolved by guessing:

concluded from vendor/x/LICENSE (text match, 100% confidence;
the text is shared by GPL-2.0-only, GPL-2.0-or-later)

Not every license file is a copy of a license, and the ones that are not are common enough to matter:

  • A license wrapped in other text. lodash's LICENSE is the MIT license with a contributors preamble, a note about the documentation's sample code and a pointer to its vendored dependencies around it. The license is in the file word for word, so it is read as MIT — unless the surrounding text adds conditions of its own (must, may not, provided that), in which case the file is not the license it contains and is reported unresolved.
  • A license notice. A LICENSE holding only the ten-line "Licensed under the Apache License, Version 2.0" block is an excerpt, not a copy. Where the notice names its license and only one license contains it, that is the answer; otherwise the finding lists what the text sits inside, so a reviewer sees Apache-2.0 (100%) rather than a similarity score of 11%.
  • A file that only names a license. modernizr ships a 31-byte LICENSE reading /*! Modernizr 3.13.1 | MIT */. A file too small to hold a license and naming exactly one identifier is read as declaring it. Two identifiers with nothing joining them conclude nothing.
  • A file that points at other files. node-gyp's packaging/LICENSE says the software is available under either of the licenses in LICENSE.APACHE or LICENSE.BSD. Those siblings are read and joined by the word the pointer uses, giving Apache-2.0 OR BSD-2-Clause. Resolution never leaves the pointer's own directory, and a target that could not be identified makes the pointer conclude nothing.

An anthology — a third-party notice file carrying several licenses in full — is none of these. Naming one of its licenses is a claim the file does not support, so it stays unresolved with its candidates listed.

Disable detection entirely with --no-detect; it is skipped automatically with --ref, which reads a commit rather than the working tree.

When the two disagree

A package that declares MIT and ships an AGPL-3.0-only license file is worth knowing about. xgrep never silently picks a side:

  • Both licenses are reported, with the path the concluded one came from.
  • The more restrictive of the two is evaluated, so a mismatch can never discharge an obligation you actually have.
  • If the two land in different categories, the result is at least a Flag even when both would otherwise be approved — misreporting across a category boundary is itself the finding.
  • A mismatch within one category (MIT declared, BSD-3-Clause shipped) is recorded but does not escalate. It is a metadata nit, not a compliance risk.

To resolve mismatches deliberately, set prefer in the policy, or pin a single package with a curation.

license:
  prefer: concluded # or: declared. Unset means "evaluate the more restrictive".

Coverage

The summary line reports what each signal contributed, so a partial answer is never presented as a complete one:

note: concluded 12 license(s) from the tree (SPDX headers and vendored license files)
note: 2 package(s) declare one license and ship another — both are reported
note: 3 of 47 packages have no license from any signal (flagged for review, never assumed clean)

A package with no signal is counted as a gap and flagged. A package answered only by detection is not a gap — it is answered, just not by the manifest.

Lockfiles that record a per-package license are read for it — composer.lock, Composer's installed.json, and npm package-lock.json (v2/v3; the legacy v1 format records no per-package license). These report a declared_source of manifest, the same as any other dependency file that states a license outright.

Flags

FlagDescription
--allow-licenses <list>Allowlist of permitted SPDX licenses, e.g. MIT,Apache-2.0. Any dependency outside the list is a finding; an unidentifiable license is flagged. Opt-in — without it, licenses are reported but not gated.
--disallow-licenses <list>Denylist of forbidden SPDX licenses, e.g. GPL-3.0-only,LGPL-3.0-only. Only these are findings; everything else, including unknown licenses, is allowed. Opt-in; mutually exclusive with --allow-licenses.
--context <name>Deployment preset: distributed-binary, saas-hosted, internal-tool, library.
--policy <file>Policy YAML. Not loaded from inside the scanned tree (untrusted); pass an explicit path.
--fail-on <deny|flag>CI gate threshold. Overrides the policy file.
--no-reachabilitySkip the code-graph build; treat every package as reachable.
--no-detectSkip concluded-license detection; read declared licenses only.
--license-fullAlso identify licenses by matching license-file text against the embedded SPDX corpus.
--obligationsList what each license requires of you, not just whether it is allowed.
--compatibilityCheck each dependency against the project's own outbound license.
--outbound <license>The project's own license (default: detected from the tree).
--ref <git-ref>Read files from a commit, tag, or branch instead of the working tree.
--ecosystem <list>Restrict to named ecosystems, e.g. go,npm.
--no-local-reposSkip declared licenses read from installed package metadata and local package repositories; report only what the repository itself states.
--resolve-licensesFetch declared licenses from each package's own service over the network. Off by default.
--resolve-repo <url>Maven repository base URL for --resolve-licenses (default: Maven Central).
--resolve-index <url>Python package index base URL for --resolve-licenses (default: PyPI).
--since <git-ref>Gate only on licenses added or changed since a ref; pre-existing findings are reported but do not fail.
--format <name>Output format: table (default), json, sarif, or gitlab.
--sbom <file>Evaluate the policy over an existing CycloneDX or SPDX document instead of scanning a source tree.
--jsonShorthand for --format json.

On this page