Skip to main content

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.yaml describing 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 the Apache-2.0 WITH Commons-Clause rider 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.0 does not fire a false GPL critical (you may elect MIT — the obligation stays visible as a warning), MIT AND GPL-3.0 fires at full severity (both apply), and restrictive WITH riders 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, pinned requirements.txt, Cargo.lock, Gemfile.lock, composer.lock) is parsed instead and licenses are resolved from the public registry (cached across runs; --offline to forbid network).
  • Policy as code: a per-repo .obligo.yaml carries auditable waivers ("we bought the n8n commercial license", with reason and expiry), a fail_on severity 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 --strict turns 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] [...]
  • --deep on scan codebase runs a slower ScanCode text-detection pass to catch license text in vendored/undeclared files that no manifest lists.
  • --offline on scan codebase forbids registry lookups (lockfile-fallback licenses become unknown-severity findings); on scan docker it only scans images already present in the local Docker daemon.
  • --strict makes 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_hosts and rejected by default when unknown; --insecure-accept-hostkeys restores 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.
  • --password on scan ssh never accepts a value; it always prompts interactively via getpass.
  • Every scan is recorded to ~/.obligo/db.sqlite; obligo history shows 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; --password is 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).

  1. Java (Maven/Gradle) support — the largest missing ecosystem; likely via pom.xml parsing + registry lookup, mirroring the lockfile fallback.
  2. BSL Change Date parsing. Extract the Change Date from a package's actual LICENSE file and re-evaluate as the post-conversion license once that date has passed.
  3. 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."
  4. Live verification of the NuGet and Composer scanners against real dotnet-project-licenses / composer licenses output.
  5. sudo fallback for SSH Docker introspection, gated behind an explicit --allow-sudo flag, for hosts where the scanning user has no other way to see container state.
  6. Team storage backend: Postgres + a minimal web view over the existing SQLite schema, so obligo history becomes a shared dashboard.
  7. 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

obligo-0.5.0.tar.gz (118.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

obligo-0.5.0-py3-none-any.whl (90.5 kB view details)

Uploaded Python 3

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

Hashes for obligo-0.5.0.tar.gz
Algorithm Hash digest
SHA256 9183b50305051028e8dc04c02c0ff21df3a42350ba71b23d563c2860459e29bd
MD5 b2e4cc4a87372c79282a180646460e58
BLAKE2b-256 0ef82d6b5b500880c136aac36b0aad97e15f4698c783f702c5e3ff9e1d874312

See more details on using hashes here.

Provenance

The following attestation bundles were made for obligo-0.5.0.tar.gz:

Publisher: release.yml on aman99dex/Obligo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for obligo-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ce5eb3b90af4b57e091387c6b352a5667d04d4c7334cf3277c912bf5bfb90073
MD5 5740e3a6a75dee0a6cc5a0c4d5371d73
BLAKE2b-256 f53a95da6d24ccca36c4897a454400939eeb539015bf5a2a8a914dfeb4482eb6

See more details on using hashes here.

Provenance

The following attestation bundles were made for obligo-0.5.0-py3-none-any.whl:

Publisher: release.yml on aman99dex/Obligo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page