Metadata-Version: 2.4
Name: obligo
Version: 0.5.1
Summary: Deployment-context-aware license compliance scanner
Project-URL: Homepage, https://github.com/aman99dex/Obligo
Project-URL: Repository, https://github.com/aman99dex/Obligo
Project-URL: Changelog, https://github.com/aman99dex/Obligo/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/aman99dex/Obligo/issues
Author: Aman Kumar Gupta
License-Expression: MIT
License-File: LICENSE
Keywords: compliance,copyleft,dependency-scanning,gpl,license,open-source,sbom,spdx
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Legal Industry
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: jinja2>=3.1
Requires-Dist: pydantic>=2.6
Requires-Dist: pyyaml>=6.0
Requires-Dist: rich>=13.7
Requires-Dist: tomli>=2.0; python_version < '3.11'
Requires-Dist: typer>=0.12
Provides-Extra: dev
Requires-Dist: hypothesis>=6.100; extra == 'dev'
Requires-Dist: paramiko>=3.4; extra == 'dev'
Requires-Dist: pytest-cov; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Provides-Extra: ssh
Requires-Dist: paramiko>=3.4; extra == 'ssh'
Description-Content-Type: text/markdown

# 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

```bash
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:

```bash
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):

```bash
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

```bash
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):

```yaml
- uses: aman99dex/Obligo@v1
  with:
    context-file: context.yaml
    fail-on: warning
    strict: "true"
```

pre-commit:

```yaml
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`):

```yaml
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

```text
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:

```yaml
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

```bash
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

```text
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
```
