@mojaloop/license-scanner-tool
v1.1.1
Published
SBOM-first license compliance gate for Mojaloop (Syft CycloneDX + curated SPDX allowlist).
Keywords
Readme
@mojaloop/license-scanner-tool
SBOM-first license-compliance gate for Mojaloop. It evaluates a CycloneDX SBOM, normalises component licences to canonical SPDX, and fails if any npm dependency's licence is not on a curated allowlist.
This replaces the legacy bash tool (Syft-less, eval/docker cp based, depended
on an unmaintained license-checker fork), which is removed in this release.
Consumers must move to mojaloop/build@>=2.0.0 (which runs this package) — CI on
the old orb path (git clone … && make build) will fail by design. See
Legacy tool.
Install / run
# one-time: install Syft (only needed when scanning a directory; see "Where the SBOM comes from")
brew install syft
# scan the current repo (the tool builds the SBOM with Syft, then gates it)
npx @mojaloop/license-scanner-tool .
# gate a pre-generated CycloneDX SBOM (no Syft needed)
npx @mojaloop/license-scanner-tool sbom.cdx.json
# non-blocking: report violations but exit 0 (used during migration windows)
npx @mojaloop/license-scanner-tool --warn .Exit codes: 0 pass (or --warn), 1 violations, 2 usage/tool error.
Using it in another repo
Four ways, best-first. (Prereqs: the package must be published; Syft on PATH for directory scans — not needed when you pass a CycloneDX SBOM; Node ≥ 20.)
1. Through the orb — automatic (recommended for CI)
If the repo's CI uses mojaloop/build@>=2.0.0, the license gate runs
automatically — the orb's license_scan / audit_licenses jobs already do
generate_sbom (Syft) → license_gate (npx @mojaloop/license-scanner-tool).
Nothing to wire per repo — just be on orb 2.0.0.
2. CLI / npm script (local or custom CI)
npx @mojaloop/license-scanner-tool . # scan source (Syft builds the SBOM)
npx @mojaloop/license-scanner-tool sbom.cdx.json # gate a pre-built SBOM (no Syft)// package.json
"scripts": { "license:check": "npx @mojaloop/license-scanner-tool ." }3. Git pre-push hook (local enforcement)
With husky:
printf 'npx --yes @mojaloop/license-scanner-tool@^0 .\n' > .husky/pre-push(or copy hooks/pre-push). Bypass once with LICENSE_CHECK_SKIP=1 git push.
4. Programmatically (library)
const { evaluate } = require('@mojaloop/license-scanner-tool') // src/gate.js
const sbom = require('./sbom.cdx.json') // a CycloneDX SBOM
const { npm, violations } = evaluate(sbom) // optional 2nd arg: custom policy
if (violations.length) { console.error(violations.join('\n')); process.exit(1) }
console.log(`OK — ${npm} npm components clean`)evaluate(sbom, policy?, now?) → { npm, violations }.
Where the SBOM comes from
The tool accepts either a CycloneDX SBOM file or a path to scan, so it works the same in CI and locally — but who builds the SBOM differs:
| Context | Who runs Syft | What the tool receives |
|---|---|---|
| CI (mojaloop/build orb) | the orb (generate_sbom) | a pre-built sbom.cdx.json — the tool consumes it, does not re-run Syft |
| Local / git hook / standalone | the tool (cli.js) | a directory (e.g. .) — the tool runs syft dir:. itself, then gates |
Why the orb builds it in CI (deliberate, "one SBOM, many gates"):
- The orb generates the SBOM once and feeds the same artifact to both the
vulnerability scan (
grype_scan) and this license gate — no double Syft runs. license_scangates a Docker image, not a source tree; the orb'sgenerate_sbomhandles images and directories uniformly. The tool's own generation path only scans source directories.
So in CI: orb creates the SBOM → tool gates it. Locally: tool creates and gates in one step. Either way the verdict is identical because both feed the same policy (data.json).
How a scan is evaluated
- Scope — only
pkg:npm/components are gated. Syft also catalogues workflow YAMLs, lockfiles and binaries; those are ignored. - Normalise — a component's licence is resolved to an SPDX id/expression
only via the curated alias map (
data.json → aliases). Proprietary markers likeUNLICENSEDare never "corrected". Anything unresolved is undetermined. (src/normalize.js) - Gate — the SPDX id/expression is checked against the allowlist;
per-package
exceptions(with expiry) waive findings. (src/gate.js)
Handling a license-scan failure
When the gate fails you get one or both of these, listed per package (all at once — the scan does not stop at the first):
DISALLOWED: npm package [email protected] uses licence "GPL-3.0-only", which is not on the allowlist (data.json -> allowed).
UNDETERMINED: npm package [email protected] declares licence "Free for all", which is not a recognised SPDX id. Verify the real licence, then add a vetted alias or exception.Do not "make it pass" blindly. A failure is a question, not a bug. Resolve each finding deliberately; every policy change is reviewed via PR and affects all Mojaloop repos that use this tool.
Step 1 — verify the real licence
Before changing anything, find out what the package is actually licensed under:
- the package's repository
LICENSEfile (most authoritative), - its
package.jsonlicensefield, - its npm page (
https://www.npmjs.com/package/<name>), - the SBOM entry — the message quotes the declared string (e.g.
"Free for all") so you can see exactly what the package put in its metadata.
Step 2 — pick the right resolution
| Finding | What you confirmed | Action |
|---|---|---|
| DISALLOWED | A valid, permissive SPDX licence missing from the allowlist (e.g. MIT-0, 0BSD) | Add the SPDX id to data.json → allowed. |
| DISALLOWED | A copyleft / incompatible licence (e.g. GPL-3.0-only, AGPL) | The gate is correct — do not allowlist it. Remove or replace the dependency. |
| UNDETERMINED | The package declares a valid licence as free text (e.g. "Apache 2.0", "BSD License") | Add a vetted entry to data.json → aliases: "<declared string>": "<SPDX id>". |
| UNDETERMINED | The package has no machine-readable licence, but you confirmed a real, acceptable licence elsewhere (e.g. MIT on GitHub, missing from package.json) | Add a dated exception to data.json → exceptions (org-wide), or — if it's specific to your repo's dependency graph — a project-local exception (see below), no central release needed. |
| UNDETERMINED | You cannot determine or trust the licence | Treat as a blocker — replace the dependency. |
Step 3 — make the change (examples)
Allow a permissive licence — add the SPDX id:
// data.json → licenses.allowed
"allowed": [ "...", "MIT-0" ]Add a vetted alias — map a verified free-text string to an SPDX id:
// data.json → licenses.aliases
"aliases": {
"Apache 2.0": "Apache-2.0" // declared string -> canonical SPDX id
}Add an exception — waive a specific package@version, with a reason and an expiry so it is revisited:
// data.json → licenses.exceptions
"exceptions": {
"[email protected]": { "reason": "MIT on GitHub, not in package.json", "expires": "2027-01-01" }
}Add a project-local exception — for an UNDETERMINED finding that's specific to
your repo's dependency graph (e.g. a transitive dep with empty license
metadata), waive it in your own repo, reviewed in your own PR, without a
central tool/orb release. Create .license-scanner.json at the repo root:
// .license-scanner.json (repo root) — auto-discovered by the CLI/orb
{
"exceptions": {
"[email protected]": { "reason": "MIT per LICENSE file; empty license field in package.json (via mysql2)", "expires": "2027-01-01" }
}
}The gate auto-discovers this file (or pass --exceptions <path>). To preserve the
central gate's integrity, project-local exceptions:
- waive UNDETERMINED findings only — a DISALLOWED (known copyleft/proprietary) licence still fails and requires a central decision;
- may define
exceptionsonly — neverallowed/aliases(those stay central; the tool errors if present); - must carry a non-empty
reasonand aYYYY-MM-DDexpires; - are overridden by a central
data.jsonexception on the samename@version(bundled wins).
Only the repo-root file (or an explicit --exceptions path) is read — nested/dependency-provided files are ignored, so a transitive dep cannot ship its own waiver.
Hard rules
- Never add
UNLICENSED(or any proprietary marker) toallowedoraliases— npm'sUNLICENSEDmeans no rights granted; the validator rejects it. - Never blanket-except an undetermined finding — verify the actual licence first.
- Exceptions must carry an
expiresdate (the validator warns on expired ones). - Prefer fixing the dependency or adding a precise alias over a broad exception.
Step 4 — validate and re-run locally
npm run validate # SPDX-lint data.json (fails on a bad id/alias/expiry)
npx @mojaloop/license-scanner-tool . # re-run the gate; should now passOpen a PR with the data.json change. Once released, every consuming repo picks
it up via the package version.
Unblocking a build while you reconcile
During a migration window (or while a fix is in review) a repo can run the gate non-blocking so it reports without failing:
npx @mojaloop/license-scanner-tool --warn .In the orb, this is the license_gate warn: true parameter. Use sparingly
— a warn-mode gate does not actually enforce compliance.
Editing the policy (data.json)
| Field | Purpose |
|---|---|
| allowed | Permitted canonical SPDX ids / expressions. |
| aliases | Reviewed free-text → SPDX mappings (reject-by-default). |
| exceptions | Per-package waivers (name@version → reason + expires). |
All entries are SPDX-validated at build time (npm run validate).
Local pre-flight & git hook
Enforce before push (repo with husky):
printf 'npx --yes @mojaloop/license-scanner-tool@^1 .\n' > .husky/pre-pushOr copy hooks/pre-push to .git/hooks/pre-push. Bypass once
with LICENSE_CHECK_SKIP=1 git push.
Development
The Node version is pinned in .nvmrc (and used by CI via nvm, matching the orb convention).
nvm use # node version from .nvmrc
npm ci
npm run validate # SPDX-lint data.json
npm test # gate unit tests (node --test)
node src/cli.js . # run the gate locallyCI / publishing
This repo uses a standalone CircleCI pipeline (.circleci/config.yml)
that publishes to npm on a vX.Y.Z tag. It deliberately does not use the
mojaloop/build orb, because this package is a dependency of that orb's license
gate — using the orb here would be circular.
Legacy tool (removed)
The previous make build / config.toml bash tool has been removed. It relied
on an unmaintained license-checker fork and used eval / docker cp — the
supply-chain and command-injection risks this package was created to eliminate.
The Makefile is now a stub that exits non-zero with upgrade guidance. If your CI
still uses the old mojaloop/build license path (git clone … && make build), it
will fail by design — upgrade to mojaloop/build@>=2.0.0, which runs
npx @mojaloop/license-scanner-tool.
