Deployment-context-aware license compliance scanner
Project description
Obligo
Obligo scans codebases, Docker images, remote servers, and Kubernetes clusters for every package they contain, normalizes each package's license, and evaluates it against how you actually deploy the software — not just what the license string says.
Why this exists
Tools like FOSSA, Snyk, and Trivy will tell you a package is licensed under "SUL" or "BSL-1.1." None of them will tell you that shipping that package to customer data centers as part of a paid product violates it. The fastest-growing category of licenses — n8n's Sustainable Use License, HashiCorp's Business Source License, MongoDB/Elasticsearch's SSPL, Redis's RSALv2/SSPLv1, Elastic License 2.0 — all restrict use based on deployment topology: who operates the software, where it runs, who pays for it, who can see its UI. Obligo is the only scanner that models that.
This project exists because a real audit found that a company had forked n8n and was preparing to ship it to customer infrastructure as a paid product — a clear SUL §7 violation that no existing scanner would have caught.
What it's good at
- Deployment-context evaluation. A single
context.yamldescribing your deployment model (internal / SaaS / distributed / embedded), who pays, who sees the UI, whether you compete with the vendor, etc. is evaluated against every package's license. The same package can be SAFE under one context and CRITICAL under another — and the tool shows you exactly which context field triggered the finding. - Real commercial license coverage: SUL-1.0 (n8n, with Startup/Business/
Enterprise tier eligibility computed from your company size), BSL-1.1
(Terraform/Vault/Consul/Nomad/Packer, including matching Go's
github.com/hashicorp/...module-path naming), SSPL-1.0 (MongoDB), RSALv2/SSPLv1 (Redis 7.4+, matched by package name even when the binary carries no license metadata at all), Elastic License 2.0, Commons Clause (including theApache-2.0 WITH Commons-Clauserider form), CC-BY-NC, PolyForm Noncommercial, and the JSON license's use restriction. - Copyleft with the right severity, not a blanket hammer: distributing a modified GPL/AGPL package with proprietary source is CRITICAL; shipping an unmodified one in a distributed proprietary app is a WARNING demanding linking review (this is the single most common real-world GPL exposure — most scanners miss it entirely); LGPL is a WARNING/INFO (static linking can't be detected from metadata alone); MPL/EPL/CDDL are file- or module-level INFO/WARNING findings.
- Real SPDX expression semantics:
MIT OR GPL-3.0does not fire a false GPL critical (you may elect MIT — the obligation stays visible as a warning),MIT AND GPL-3.0fires at full severity (both apply), and restrictiveWITHriders like Commons Clause are never silently dropped. - Works on a bare checkout: when the installed environment a scanner
needs is missing (no
node_modules, no venv), the ecosystem's lockfile (package-lock.json,yarn.lock,pnpm-lock.yaml,poetry.lock,uv.lock, pinnedrequirements.txt,Cargo.lock,Gemfile.lock,composer.lock) is parsed instead and licenses are resolved from the public registry (cached across runs;--offlineto forbid network). - Policy as code: a per-repo
.obligo.yamlcarries auditable waivers ("we bought the n8n commercial license", with reason and expiry), afail_onseverity threshold for CI, and a custom-rules directory that layers over the built-in database. - Four scan surfaces: local codebases (npm/pip/go/cargo/bundler/composer/ nuget), Docker images (via Syft), live servers over SSH (direct or through a bastion two-hop, with real host-key verification), and Kubernetes namespaces.
- Never silently clean. Uncatalogued licenses become INFO findings,
unrecognized strings become UNKNOWN findings, skipped scanners land in the
report's
warnings(and--strictturns any of them into a failing exit code), and unreachable remote images are listed as explicitly NOT scanned. - CI-native output: exit-code thresholds (
--fail-on warning), pure-JSON output, CycloneDX 1.5 SBOM export for Dependency-Track, and SARIF 2.1.0 for GitHub code-scanning annotations — plus a GitHub Action, a pre-commit hook, and a batteries-included Docker image. - Battle-tested: 276 unit tests, 1,300+ property-based fuzz executions, and verified against real-world Docker images including the actual n8n image (correctly flags CRITICAL under a distributed+paid context, clean under internal-only).
Installation
pipx install obligo # recommended for CLI use (once published)
pip install obligo # or plain pip
pip install 'obligo[ssh]' # + SSH remote scanning (pulls paramiko)
python -m obligo --help # works without PATH setup
From a checkout:
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"
Or use the batteries-included Docker image (ships syft, pip-licenses, and license-checker so nothing else needs installing):
docker build -t obligo .
docker run --rm -v "$PWD:/scan" obligo scan codebase /scan -c /scan/context.yaml
External tools Obligo shells out to when present (all optional — for npm/pip/cargo/bundler/composer the lockfile fallback covers their absence):
| Tool | Needed for | Install |
|---|---|---|
syft |
Docker image scanning | brew install syft |
pip-licenses |
Python codebase scanning (installed envs) | pip install pip-licenses |
license-checker |
npm codebase scanning (installed envs) | npm install -g license-checker |
go-licenses |
Go codebase scanning | go install github.com/google/go-licenses@latest |
cargo-license |
Rust codebase scanning | cargo install cargo-license |
license_finder |
Ruby codebase scanning | gem install license_finder |
composer |
PHP codebase scanning | brew install composer (or see getcomposer.org) |
dotnet-project-licenses |
.NET codebase scanning | dotnet tool install --global dotnet-project-licenses |
scancode + libmagic |
--deep vendored-license text detection |
pip install scancode-toolkit + brew install libmagic |
kubectl |
Kubernetes scanning | brew install kubectl |
docker |
--offline local-image scanning |
already required for most workflows |
Run obligo doctor any time to see what's installed and what's missing.
Quick start
obligo init # writes context.yaml with every field commented
obligo db list # every license in the rules database
obligo db show SUL-1.0 # full rule: conditions, remediation, tiers
obligo db add # interactive wizard; saves to ~/.obligo/rules/
obligo scan codebase ./my-project --context context.yaml
obligo scan codebase ./my-project -f sarif -o obligo.sarif # GitHub code scanning
obligo scan codebase ./my-project -f cyclonedx -o sbom.json # Dependency-Track
obligo scan docker n8nio/n8n:latest --context context.yaml
obligo scan ssh ubuntu@host --key ./key.pem --context context.yaml
obligo scan k8s --namespace production --context context.yaml
Exit codes are CI-friendly: 0 clean, 1 when any finding meets the
fail_on threshold (default: critical — raise it with --fail-on warning
or the policy file), 2 for a tool/usage error or, with --strict, an
incomplete scan.
CI in one step
GitHub Actions (composite action in this repo):
- uses: aman99dex/Obligo@v1
with:
context-file: context.yaml
fail-on: warning
strict: "true"
pre-commit:
repos:
- repo: https://github.com/aman99dex/Obligo
rev: v0.5.0
hooks:
- id: obligo
Policy file (.obligo.yaml)
Decisions the scan can't know about live in a per-repo policy file, picked
up automatically (or via --policy):
fail_on: warning # findings at/above this severity fail CI (critical|warning|unknown|info|never)
rules_dir: ./license-rules # extra rules layered OVER the built-in DB (same id = override)
waivers:
- rule_id: SUL-1.0
packages: ["n8n*"]
reason: "Commercial license purchased 2026-01, contract #1234"
expires: 2027-01-01 # omit for perpetual; expired waivers stop applying, loudly
Waivers are auditable, not silent: every suppressed finding travels with the
report (JSON waived key, dim terminal lines), and an expired waiver
surfaces as a warning until renewed or removed.
Command reference
obligo init [path] [--force]
obligo doctor
obligo limitations
obligo history [--limit N]
obligo db list | db show <license-id> | db add
obligo bundle create [--output FILE] [--include-syft/--no-syft]
obligo scan codebase <path> --context <file> [--deep] [--offline] [--strict]
[--policy FILE] [--fail-on LEVEL]
[--format terminal|html|json|cyclonedx|sarif] [--output FILE] [--quiet]
obligo scan docker <image> --context <file> [--offline] [--strict] [...]
obligo scan ssh <user@host> --context <file> [--key PEM] [--target-key PEM]
[--bastion user@host] [--port N] [--password]
[--insecure-accept-hostkeys] [--strict] [...]
obligo scan k8s [--namespace NS] [--kubeconfig FILE] --context <file> [--strict] [...]
--deeponscan codebaseruns a slower ScanCode text-detection pass to catch license text in vendored/undeclared files that no manifest lists.--offlineonscan codebaseforbids registry lookups (lockfile-fallback licenses become unknown-severity findings); onscan dockerit only scans images already present in the local Docker daemon.--strictmakes any scan warning (skipped scanner, unreachable image) exit 2 — an incomplete scan can no longer pass a CI gate as clean.- SSH host keys are verified against
known_hostsand rejected by default when unknown;--insecure-accept-hostkeysrestores blind trust if you accept the MITM risk. Remote container images are pulled and scanned with the local syft — images only present in the remote daemon are reported as explicitly NOT scanned. --passwordonscan sshnever accepts a value; it always prompts interactively viagetpass.- Every scan is recorded to
~/.obligo/db.sqlite;obligo historyshows past runs. A failure to record never blocks the report.
The context.yaml model
The one file every evaluation depends on. Only deployment_model and
company are required — every other field defaults to a conservative value
so a minimal file still validates:
deployment_model: distributed # internal | saas | distributed | embedded
runs_on_customer_hardware: true
customers_see_ui: false
customers_pay: true
you_are_the_operator: true
you_distribute_modified_version: true
you_compete_with_vendor: false
users_interact_over_network: true
source_is_proprietary: true
you_offer_as_managed_service: false
you_compete_with_elastic: false
modified_packages: ["n8n*"] # glob patterns for packages you actually modified
company:
employee_count: 15
total_funding_usd: 2000000
When modified_packages is set it is authoritative for every ecosystem:
only listed packages count as modified (the rest are evaluated as
distributed-unmodified, which for copyleft is a warning, not a critical).
When it isn't set, the global flag applies to all non-OS packages, and
OS-level packages (ecosystem: os, e.g. Alpine base-image contents)
conservatively default to "not modified" so forking n8n doesn't flag
busybox under GPL.
A rule's condition is a dict of these fields — all keys must match (AND).
Multiple violations on one rule are OR'd against each other (this is how
Elastic-2.0 expresses "competes with Elastic OR offers a managed service"
without a bespoke boolean-logic DSL). Numeric fields support operators:
{company.employee_count: {lte: 20}}.
What it deliberately does NOT do
- No CVE/vulnerability scanning. Use Trivy or Grype for that — mixing license and security concerns in one tool muddies both.
- No auto-remediation. It reports; a human decides whether to buy a license, fork, or migrate.
- No credential storage. SSH keys are referenced by path and never
logged;
--passwordis interactive-only.
Known limitations (read this before trusting a clean scan)
This section mirrors obligo/limitations.py (also shown by
obligo limitations, in every scan banner, and in JSON/HTML output);
a unit test keeps the two in sync.
Modification tracking is opt-in per package for OS packages. When modified_packages is set it is authoritative for every ecosystem: only listed packages count as modified. When it is NOT set, you_distribute_modified_version still applies globally to all non-OS packages (OS-level packages always default to 'not modified' unless listed). The remaining edge case: forgetting to list a package you did modify silently under-reports it as merely 'distributed unmodified' (a warning, not the modified-distribution critical). Double-check modified_packages covers everything you actually changed.
Go monorepo package-pattern matching is a per-case allowlist, not a structural fix. BSL-1.1's patterns (*/hashicorp/consul/*, etc.) are needed because syft reports Go packages by full module path, not bare name — but the same glob that correctly catches Terraform's own internal subpackages also catches sibling packages published from the same repo under a different license. The confirmed real-world case (hashicorp/consul/api, actually MPL-2.0, swept into a false BSL-1.1 finding in grafana/grafana, prometheus, influxdb, and traefik) is now fixed via an explicit package_exclude_patterns entry on the BSL-1.1 rule. The residual risk runs the other direction: an as-yet-undiscovered sibling package under a different license would still be swept in until someone adds it to package_exclude_patterns.
Multi-license findings can't distinguish 'pick one' from 'all apply'. SPDX OR (choice) and AND (all apply) are now evaluated with their real semantics: an AND component always fires at full severity, and an OR violation is downgraded to a warning when a provably clean alternative branch exists (you can elect the clean license instead). The residual ambiguity: strings joined with ; or / — and Debian-style copyright aggregates where each license covers different files (e.g. libwmf listing AGPL-3, GD, ISC, LGPL-2) — are treated as a choice (OR). For a per-file aggregate that guess is wrong and the downgraded warning should really be a critical; the finding message says so explicitly, but the severity can't.
syft frequently returns no license metadata for compiled binaries. The redis and terraform binaries both come back with licenses: [] from syft. Obligo covers known cases via package-name pattern matching, but any commercial license not yet in the rules database, on a package syft can't fingerprint by content, will silently read as "unknown" rather than being caught.
No version-specific license tracking. MongoDB, Elasticsearch, and Redis have all changed licenses across versions. The rules database reflects each package's current dominant license family, not necessarily the exact terms of the specific version you're running.
BSL's "Change Date" time-bomb isn't parsed. The Business Source License auto-converts to a GPL-compatible license after a fixed date (typically 4 years). Obligo flags BSL-1.1 packages as commercial-risk unconditionally and does not check whether a given release has already converted.
Rules database is a minimum viable set, not exhaustive. 20 safe licenses, 15 copyleft, 9 commercial families. Real-world scans will surface "uncatalogued license" INFO findings for legitimate-but-unmodeled licenses — intentional (never silently clean), but noise scales with codebase diversity.
Ecosystem coverage: npm, pip, go, cargo, bundler, composer, nuget. No pub (Dart) or SPM (Swift) support — neither has a per-dependency license CLI as mature as pip-licenses/license-checker/go-licenses, so rather than ship a weak approximation these are still unsupported. scan codebase silently skips ecosystems it doesn't recognize rather than erroring, so a Dart-heavy monorepo could return a suspiciously clean result.
Lockfile fallback trusts the lockfile and the registry. When the installed environment a scanner needs is missing, the ecosystem's lockfile is parsed instead and licenses come from the public registry (or composer.lock directly). This can't see anything an actual install would add or change (postinstall scripts, environment-specific resolution), skips requirements.txt lines that aren't exact == pins, and inherits whatever license metadata the registry has — which for PyPI in particular is sometimes stale or free-text. Registry-unresolvable packages surface as unknown-severity findings, never as clean.
NuGet and Composer scanners are implemented from documented tool behavior, not live-verified. The Cargo (cargo-license) and Bundler (license_finder) scanners were built and verified against real tool output — that testing is what caught license_finder's exit-1-on-unapproved-dependencies and banner-before-JSON behavior, neither of which is obvious from its docs alone. The NuGet (dotnet-project-licenses) and Composer (composer licenses) scanners were written from documented output shapes only (no .NET SDK/PHP toolchain in the environment they were developed in) and have not had the same live verification. Parsing is defensive — a real-world shape surprise degrades to a warning and an empty result rather than a crash or a wrong-but-plausible package list — but treat findings from these two specifically with extra scrutiny until verified against a real project.
SSH scanning needs Docker-group access on the remote host. If the SSH user can't reach the Docker socket, the tool prints a loud "findings may be INCOMPLETE" warning rather than a silent clean scan — but it still can't see what it can't see. It also doesn't attempt sudo.
--offline requires a running local Docker daemon. Cannot fall back to a pre-exported tarball or OCI layout.
The air-gap bundle excludes ScanCode. Its dependency tree is too large to vendor; the bundle's manifest documents this and tells you to pip download it separately if the target environment needs the --deep pass.
Storage is single-user, local SQLite only. No team dashboard, no Postgres backend, no cross-laptop history.
Roadmap
Roughly in order of value-per-effort. Items shipped so far: per-package modification tracking (0.2.0), seven manifest ecosystems (0.3.0), SPDX expression semantics + partial-scan honesty + SSH hardening (0.4.0), lockfile static scanning, policy/waivers, CycloneDX/SARIF export, GitHub Action/Docker/pre-commit distribution (0.5.0).
- Java (Maven/Gradle) support — the largest missing ecosystem; likely
via
pom.xmlparsing + registry lookup, mirroring the lockfile fallback. - BSL Change Date parsing. Extract the
Change Datefrom a package's actual LICENSE file and re-evaluate as the post-conversion license once that date has passed. - Version-aware license history for packages known to have relicensed (Elasticsearch, Redis, MongoDB): a version → license-family map per rule, so a scan can say "this specific version is still permissive."
- Live verification of the NuGet and Composer scanners against real
dotnet-project-licenses/composer licensesoutput. sudofallback for SSH Docker introspection, gated behind an explicit--allow-sudoflag, for hosts where the scanning user has no other way to see container state.- Team storage backend: Postgres + a minimal web view over the existing
SQLite schema, so
obligo historybecomes a shared dashboard. - Bundle ScanCode for true air-gap parity, or replace it with a lighter pure-Python license-text matcher for the common ~50 license texts.
Testing
pytest tests/ # 276 tests
pytest tests/test_fuzz.py -v # property-based fuzzing (hypothesis)
pytest tests/ --cov=obligo --cov-report=term-missing
ruff check obligo tests
tests/test_fuzz.py fuzzes normalize(), evaluate(), Context
validation, and context_matches() against adversarial/unicode/malformed
input — these caught two real bugs (an infinite-recursion path in license
normalization and an unhandled TypeError on malformed rule conditions)
that no hand-written test would have found.
Project layout
obligo/
├── cli.py # Typer app — every command
├── policy.py # .obligo.yaml: waivers, fail_on, rules_dir
├── limitations.py # single source of truth for known blind spots
├── storage.py # SQLite scan history
├── bundle.py # air-gap bundle creation
├── rules_wizard.py # interactive `db add` rule builder
├── errors.py # ObligoError
├── core/
│ ├── context.py # Pydantic Context/Company models + loader
│ ├── evaluator.py # SBOM × Context → Findings (SPDX OR/AND, dedup, tiers)
│ ├── license_normalize.py # raw license string → canonical id
│ ├── sbom.py # Package/SBOM data model
│ └── severity.py # shared severity ordering/styling
├── license_db/
│ ├── loader.py # rule loading, validation, layering, context_matches()
│ └── rules/{safe,copyleft,commercial,no_license}.yaml
├── scanner/
│ ├── codebase.py # manifest discovery, orchestration, docker-compose, ScanCode
│ ├── manifests.py # the 7 per-ecosystem tool-backed scanners
│ ├── lockfiles.py # static lockfile parsing + registry license lookup
│ ├── docker_image.py # syft wrapper, registry auth detection
│ ├── remote.py # SSH direct + bastion two-hop (host-key verified)
│ └── process_list.py # Kubernetes (kubectl + syft)
├── reporter/
│ ├── terminal.py # Rich table, --json, exit codes
│ ├── standards.py # CycloneDX 1.5 + SARIF 2.1.0 export
│ └── html.py + templates/ # self-contained dark-theme report
└── templates/context.yaml.j2 # `obligo init` template
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file obligo-0.5.0.tar.gz.
File metadata
- Download URL: obligo-0.5.0.tar.gz
- Upload date:
- Size: 118.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9183b50305051028e8dc04c02c0ff21df3a42350ba71b23d563c2860459e29bd
|
|
| MD5 |
b2e4cc4a87372c79282a180646460e58
|
|
| BLAKE2b-256 |
0ef82d6b5b500880c136aac36b0aad97e15f4698c783f702c5e3ff9e1d874312
|
Provenance
The following attestation bundles were made for obligo-0.5.0.tar.gz:
Publisher:
release.yml on aman99dex/Obligo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
obligo-0.5.0.tar.gz -
Subject digest:
9183b50305051028e8dc04c02c0ff21df3a42350ba71b23d563c2860459e29bd - Sigstore transparency entry: 2128111085
- Sigstore integration time:
-
Permalink:
aman99dex/Obligo@002f91c03c22722aad995b373a65b90369c3bd84 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/aman99dex
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@002f91c03c22722aad995b373a65b90369c3bd84 -
Trigger Event:
push
-
Statement type:
File details
Details for the file obligo-0.5.0-py3-none-any.whl.
File metadata
- Download URL: obligo-0.5.0-py3-none-any.whl
- Upload date:
- Size: 90.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ce5eb3b90af4b57e091387c6b352a5667d04d4c7334cf3277c912bf5bfb90073
|
|
| MD5 |
5740e3a6a75dee0a6cc5a0c4d5371d73
|
|
| BLAKE2b-256 |
f53a95da6d24ccca36c4897a454400939eeb539015bf5a2a8a914dfeb4482eb6
|
Provenance
The following attestation bundles were made for obligo-0.5.0-py3-none-any.whl:
Publisher:
release.yml on aman99dex/Obligo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
obligo-0.5.0-py3-none-any.whl -
Subject digest:
ce5eb3b90af4b57e091387c6b352a5667d04d4c7334cf3277c912bf5bfb90073 - Sigstore transparency entry: 2128111374
- Sigstore integration time:
-
Permalink:
aman99dex/Obligo@002f91c03c22722aad995b373a65b90369c3bd84 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/aman99dex
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@002f91c03c22722aad995b373a65b90369c3bd84 -
Trigger Event:
push
-
Statement type: