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:
- reads two signals — the license declared in a manifest or lockfile, and the license concluded from the tree itself (see Two license signals),
- normalizes them to SPDX identifiers, parsing
expressions like
MIT OR Apache-2.0, - classifies the result into a category (permissive → copyleft → network-copyleft → commercial), and
- 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 reportGating 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.0names 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-onlypasses an allowlist containingMIT); a combinedA AND Bneeds both. -
A denylist —
--disallow-licenses GPL-3.0-only,LGPL-3.0-onlyis 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-onlyis not denied — the consumer can take MIT); a combinedA AND Bis 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-hostedgates 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.yamlfor a persistent, auditable version of either of the above (anallow: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:
| Context | Use when | Disposition highlights |
|---|---|---|
distributed-binary (default) | You ship a binary or bundle to others | Strong copyleft (GPL) denied, weak copyleft flagged |
saas-hosted | Server-side / SaaS, not distributed | Network copyleft (AGPL/SSPL) denied, plain GPL flagged, weak copyleft allowed |
internal-tool | Never leaves your organization | Almost every OSS license allowed; only commercial is flagged |
library | You distribute a library others embed | Strictest: 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 isDuties 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:
| Scope | Reaches |
|---|---|
file | Only the licensed files you modified (MPL, CDDL) |
library | The library itself, not your application (LGPL) |
derivative-work | The whole derived work (GPL) |
network | The 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 publishedYour 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 achoicesentry 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.
| finding | what it means | what to do |
|---|---|---|
undetermined | no licence evidence exists at all | find out; the package is never assumed clean |
unresolved | a licence file was read and could not be identified | read it — the finding names what it came closest to |
ambiguous | the text matches several identifiers equally | read the licensed file's own header, which is where the distinction lives |
choice-unpinned | the licence offers a choice and none is pinned | record 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-08Note 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 separatelyreason, 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 boundaryThe 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.sarifFindings 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.
| Flag | Description |
|---|---|
--license | Enable the license gate (off by default). |
--license-full | Also identify licenses by matching license-file text. |
--license-context | Deployment context preset. |
--license-fail-on | Gate threshold: deny or flag. |
--license-format | Report 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.jsonTwo 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.0CycloneDX (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_source | Meaning |
|---|---|
manifest | The dependency file stated it. |
lockfile | Recovered from a lockfile field. |
metadata | Read from installed package metadata — a Python dist-info, a .nuspec, a jar manifest. |
local-repo | Read from a local package repository or environment outside your tree — ~/.m2/repository, the Gradle cache, the NuGet package folder, an installed Python environment. |
registry | Fetched over the network, which only happens with --resolve-licenses. |
none | The dependency file carries a license field and this package left it empty. |
unavailable | The 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.2Gradle 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.70A 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.3Python 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:
| Where | Found via |
|---|---|
| An activated virtualenv, anywhere on disk | VIRTUAL_ENV |
| A poetry, pipenv, hatch or uv environment | VIRTUAL_ENV, once activated |
| A conda environment | CONDA_PREFIX |
pip install --user | the 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.17Three 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
LICENSEis 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
LICENSEholding 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 seesApache-2.0 (100%)rather than a similarity score of 11%. - A file that only names a license. modernizr ships a 31-byte
LICENSEreading/*! 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/LICENSEsays the software is available under either of the licenses inLICENSE.APACHEorLICENSE.BSD. Those siblings are read and joined by the word the pointer uses, givingApache-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 (
MITdeclared,BSD-3-Clauseshipped) 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
| Flag | Description |
|---|---|
--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-reachability | Skip the code-graph build; treat every package as reachable. |
--no-detect | Skip concluded-license detection; read declared licenses only. |
--license-full | Also identify licenses by matching license-file text against the embedded SPDX corpus. |
--obligations | List what each license requires of you, not just whether it is allowed. |
--compatibility | Check 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-repos | Skip declared licenses read from installed package metadata and local package repositories; report only what the repository itself states. |
--resolve-licenses | Fetch 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. |
--json | Shorthand for --format json. |
Reachability
xgrep ranks dependency vulnerabilities by whether your code reaches them, down to the individual vulnerable function, and shows the call path that proves it.
Attribution
Generate the third-party attribution document — components, licenses, copyright holders and license texts — deterministically, with a CI gate that catches it going stale.