From 84389683c274f01d3184bf8dcf0fb49d3909b05a Mon Sep 17 00:00:00 2001 From: Ajoy L Date: Mon, 14 Sep 2026 21:59:11 -0500 Subject: [PATCH 1/2] feat(licenses): resolve requirements.txt pin licenses from PyPI and add --offline Library components parsed from requirements.txt now carry the license their package declares on PyPI. For each exact == pin the scan reads pypi.org/pypi///json and writes the result into the component's licenses[] (with an aisbom:license:source=pypi property), and into licenseDeclared for SPDX 2.3 output. Unpinned requirements are skipped. Sources are tried from most to least precise: PEP 639 license_expression, an SPDX id or expression in the license field, a short table of unambiguous free-text spellings, a classifier naming exactly one license, then any other short declaration kept as a license name. License texts pasted into the field and ambiguous classifiers (BSD License) resolve to nothing. Best-effort: a PyPI outage, rate limit, unknown package or exhausted time budget costs that dependency its license and prints a warning; exit codes and model findings never change. legal_status is never derived from it. Answers are cached in ~/.aisbom/pypi_license_cache.json (30 days resolved, 24 hours unresolved, failures never cached), and an all-hit run does not rewrite it. --offline (scan, score) or AISBOM_OFFLINE=1 makes no network access of any kind: no PyPI or OSV lookup, no telemetry, no update check. Remote targets and --share are refused up front. The subprocess CLI tests now run with AISBOM_OFFLINE=1: they are out of reach of the conftest stubs and were reaching the network from the suite. --- .github/workflows/ci.yml | 10 + README.md | 58 +++- action.yml | 2 +- action/README_ACTION.md | 4 +- aisbom/cli.py | 113 +++++++- aisbom/cyclonedx_gen.py | 21 +- aisbom/offline.py | 32 +++ aisbom/pypi.py | 483 ++++++++++++++++++++++++++++++++ aisbom/score.py | 5 +- aisbom/spdx_gen.py | 20 +- aisbom/telemetry.py | 5 + aisbom/version_check.py | 4 +- docs/air-gapped-guide.md | 8 +- tests/conftest.py | 27 ++ tests/test_cli_integration.py | 5 + tests/test_cyclonedx_gen.py | 89 ++++++ tests/test_pypi.py | 505 ++++++++++++++++++++++++++++++++++ tests/test_pypi_cli.py | 297 ++++++++++++++++++++ tests/test_spdx.py | 44 +++ 19 files changed, 1710 insertions(+), 22 deletions(-) create mode 100644 aisbom/offline.py create mode 100644 aisbom/pypi.py create mode 100644 tests/test_pypi.py create mode 100644 tests/test_pypi_cli.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 059ca11..5008a10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,3 +84,13 @@ jobs: if poetry run aisbom score sbom.json --fail-under 101; then echo "::error::--fail-under did not gate on a failing score"; exit 1 fi + + # --offline must still produce an SBOM, and must refuse a target that + # can only be reached over the network rather than quietly fetching it. + - name: Smoke Test #13 - Offline Mode + run: | + poetry run aisbom scan demo_data --offline --output offline-sbom.json --no-fail-on-risk + test -s offline-sbom.json + if poetry run aisbom scan hf://google-bert/bert-base-uncased --offline --no-fail-on-risk; then + echo "::error::--offline scanned a remote target"; exit 1 + fi diff --git a/README.md b/README.md index a9f25bb..8ef639e 100644 --- a/README.md +++ b/README.md @@ -318,8 +318,9 @@ the other ids listed as `aliases`. unexpected, the run prints a warning and the VEX documents simply carry no CVE statements. Exit codes and model findings are unchanged. -A plain `aisbom scan` without `--vex` never contacts OSV. To keep `--vex` fully -offline, pass `--no-osv` or set `AISBOM_NO_OSV=1`. +A plain `aisbom scan` without `--vex` never contacts OSV. To skip only the OSV +lookup, pass `--no-osv` or set `AISBOM_NO_OSV=1`; to make no network access at +all, use [`--offline`](#offline-and-air-gapped-scans). #### Remediation evidence (`fixed`) @@ -334,6 +335,49 @@ aisbom scan . --vex --vex-baseline last-release-sbom.json --output sbom.json Baselines predating structured findings still work: the finding is recovered from the component description rather than reported as a spurious `fixed`. +### Dependency licenses from PyPI + +Library components parsed from `requirements.txt` carry the license their +package declares on PyPI. For each exact pin (`transformers==5.13.1`) the scan +reads that release's metadata from `https://pypi.org/pypi///json` +and writes the result into the component's standard `licenses[]` field, with an +`aisbom:license:source` property of `pypi` so you can tell a registry +declaration from something read out of the file itself. + +- **Exact pins only.** `torch>=2.0` doesn't say which release is installed, and + licenses occasionally change between releases, so ranges are skipped and + counted in the scan summary. +- **Nothing is guessed.** The PEP 639 `License-Expression` is used when present, + then a valid SPDX identifier or expression in the `license` field, then a + classifier that names exactly one license. A license *text* pasted into the + field, or a classifier such as `BSD License` that doesn't say which BSD, + produces no license rather than a wrong one. Other short declarations + (`Proprietary`) are kept as a license name. +- **A declaration, not a verdict.** The license never feeds the legal-risk + status shown for model files. +- **SPDX 2.3** output carries SPDX-valid values as `licenseDeclared`; + `licenseConcluded` stays `NOASSERTION`. +- **Cached** in `~/.aisbom/pypi_license_cache.json` — 30 days for a resolved + license, 24 hours for a package that declares none — so repeat CI scans of the + same pins make no requests. +- **Never breaks a scan.** If PyPI is unreachable or rate-limits the lookup, the + affected dependencies carry no license, the run prints a warning, and exit + codes are unchanged. + +### Offline and air-gapped scans + +```bash +aisbom scan ./models --offline +``` + +`--offline` (or `AISBOM_OFFLINE=1`) makes no network access of any kind: no +PyPI license lookup, no OSV lookup, no telemetry and no update check. A remote +target (`hf://`, `https://`) or `--share` is refused with exit `1` rather than +fetched, since each needs the network. The SBOM is otherwise the same, minus +the dependency licenses and CVE statements those lookups would have added. +`aisbom score --offline` works the same way. See the +[air-gapped guide](docs/air-gapped-guide.md). + ### Completeness score (`aisbom score`) A scan can come back perfectly clean and still produce an SBOM that names no @@ -491,6 +535,8 @@ Without a `token` the scan runs exactly as before — no `--vex`, no VEX files w **Anonymous telemetry — on by default,** as described in [Telemetry & Privacy](#telemetry--privacy). `AISBOM_NO_TELEMETRY=1` disables telemetry only; it does not suppress either upload above. +**Package registry lookups — on by default.** Exact `requirements.txt` pins (package name and version only) are looked up on `pypi.org` from the runner to fill in [dependency licenses](#dependency-licenses-from-pypi), and — when `token` is set, which turns on `--vex` — in the OSV database. No SBOM content is sent. Set `AISBOM_OFFLINE=1` in the step's `env:` to skip both; it also disables the CLI's telemetry, and it cannot be combined with `share: true`, which needs the network. + For the two upload paths the payload is the SBOM — names, hashes, licenses, risk levels — plus, on the dashboard path only, the VEX documents derived from those same findings. All of it describes the *structure and findings* of your model files, never the weights or file contents. Telemetry carries none of that: no SBOM, no VEX, no file names, no hashes, no repo identifier. > **Changed in v1.4.0.** Sharing used to be unconditional: every Action run published its SBOM to a public 30-day link whether or not `token` was set, which contradicted the paragraph above. It is now opt-in and off by default. If you consume the `share-url` output or want the viewer link in your PR comments, set `share: true`. @@ -642,7 +688,11 @@ Each event carries an anonymous `user_id` — a SHA-256 of your machine's MAC ad ### OSV lookups (`--vex` only) -When you pass `--vex` and the scan finds exact `requirements.txt` pins, AIsbom sends each pinned **package name and version** to the public OSV API at `https://api.osv.dev`, and fetches the advisories it names. Nothing else is sent: no file paths, model names, hashes, findings, or identifiers. This is a request to a third-party service, not telemetry, so `AISBOM_NO_TELEMETRY` does not affect it; `--no-osv` or `AISBOM_NO_OSV=1` does. Responses are cached locally in `~/.aisbom/osv_cache.json` for 24 hours, and deleting that file is always safe. +When you pass `--vex` and the scan finds exact `requirements.txt` pins, AIsbom sends each pinned **package name and version** to the public OSV API at `https://api.osv.dev`, and fetches the advisories it names. Nothing else is sent: no file paths, model names, hashes, findings, or identifiers. This is a request to a third-party service, not telemetry, so `AISBOM_NO_TELEMETRY` does not affect it; `--no-osv`, `AISBOM_NO_OSV=1` or `--offline` does. Responses are cached locally in `~/.aisbom/osv_cache.json` for 24 hours, and deleting that file is always safe. + +### PyPI license lookups (on by default) + +When a scan finds exact `requirements.txt` pins and writes CycloneDX or SPDX 2.3 output, AIsbom requests each pinned **package name and version** from the public PyPI JSON API at `https://pypi.org/pypi///json` to read its declared license. Nothing else is sent: no file paths, model names, hashes, findings, or identifiers. Like the OSV lookup this is a request to a third-party service, not telemetry, so `AISBOM_NO_TELEMETRY` does not affect it; `--offline` or `AISBOM_OFFLINE=1` does. Answers are cached locally in `~/.aisbom/pypi_license_cache.json`, and deleting that file is always safe. ### What's never collected @@ -660,6 +710,8 @@ export AISBOM_NO_TELEMETRY=1 AISBOM_NO_TELEMETRY=1 aisbom scan ./my-project ``` +`AISBOM_OFFLINE=1` (or `--offline` on `scan` and `score`) goes further: it disables telemetry and also every other network request — the update check and the PyPI and OSV lookups. + ### Where the data goes Events POST to `https://api.aisbom.io/v1/telemetry` (a Cloudflare Worker we operate), which sanitizes the payload and forwards to Google Analytics 4 on the dedicated `cli.aisbom.io` data stream. We don't share, sell, or use this data for ad targeting. diff --git a/action.yml b/action.yml index 21e97b1..f90d0a4 100644 --- a/action.yml +++ b/action.yml @@ -9,7 +9,7 @@ branding: inputs: directory: - description: 'Directory to scan for AI model artifacts.' + description: 'Directory to scan for AI model artifacts. Exact requirements.txt pins found in it (package name and version only) are looked up on pypi.org from the runner to fill in their declared licenses; set AISBOM_OFFLINE=1 in the step env to skip network lookups.' required: true default: '.' output-file: diff --git a/action/README_ACTION.md b/action/README_ACTION.md index 7c68d15..39b020f 100644 --- a/action/README_ACTION.md +++ b/action/README_ACTION.md @@ -114,7 +114,7 @@ The Action embeds a hidden `` marker in the comment body. ## Data flow & privacy -Scans run inside the Action container; the model files themselves never leave the GitHub runner. Three things can be sent over the wire, each with its own switch: +Scans run inside the Action container; the model files themselves never leave the GitHub runner. Four things can be sent over the wire, each with its own switch: 1. **SBOM share upload — off by default, enabled by `share: true`.** The rendered CycloneDX JSON is POSTed to `aisbom.io/api/sbom-share`, which mints a **publicly-readable** viewer link retained for 30 days; the unguessable URL token is the only access control. With `share` unset — the default — no request is made to `aisbom.io` and the `share-url` output is empty. Note that on a public repository the Action prints that URL into the workflow log, which is itself public. 2. **Hosted dashboard upload — off by default, enabled by setting `token`.** The same CycloneDX JSON is POSTed to `https://app.aisbom.io/v1/scan-result` (or your `platform-url` override) along with the branch/tag name (`GITHUB_REF_NAME`), so your dashboard at [app.aisbom.io](https://app.aisbom.io) can track the repo's SBOM history. Data is stored in the EU. The upload is logged loudly in your CI output every time it happens. Remove the token to stop. @@ -122,6 +122,8 @@ Scans run inside the Action container; the model files themselves never leave th Setting `token` also runs the scan with `--vex`, and the two resulting VEX documents (OpenVEX and CycloneDX VEX) are uploaded in that same request as `{"sbom": …, "vex": [...]}`. They are derived entirely from findings already present in the SBOM — per finding, whether each scanned artifact is actually affected — and add no new information about your files; they are what lets the dashboard show whether a finding is *exploitable* rather than merely present. The log group reports how many were sent (`vex-documents=N`). With no `token`, `--vex` is not passed, no VEX files are written into your workspace, and no request is made. 3. **Anonymous telemetry — on by default.** Two events (`github_action_run` and `github_action_comment_posted`) are POSTed to `api.aisbom.io/v1/telemetry`, plus the CLI's own scan events. No repo identifier, no file paths, no findings content — just severity buckets and whether the comment was created vs updated. Set `AISBOM_NO_TELEMETRY=1` in your workflow's `env:` block to disable. +4. **Package registry lookups — on by default.** Exact `requirements.txt` pins found in `directory` (package name and version only) are requested from `pypi.org` to fill in each dependency's declared license, and, when `token` is set (which turns on `--vex`), looked up in the OSV database at `api.osv.dev` for CVE statements. No SBOM content, file names or repo identifiers are sent. Set `AISBOM_OFFLINE=1` in your workflow's `env:` block to skip both; it also disables the CLI's own telemetry events, and it cannot be combined with `share: true`, which needs the network. + For the two upload paths (1 and 2) the payload is the SBOM — file names, SHA-256 hashes, licenses, risk and legal findings — plus, on path 2 only, the VEX documents derived from those same findings. Never model weights or file contents. Telemetry (3) carries none of that: no SBOM, no VEX, no file names, no hashes, no repo identifier — just event names and low-cardinality parameters such as severity counts. `AISBOM_NO_TELEMETRY=1` disables (3) only. It does **not** suppress the share upload: with `share: true` the SBOM is still uploaded, and only the `cli_share_created` event is withheld. Leave `share` unset to stop the upload itself. diff --git a/aisbom/cli.py b/aisbom/cli.py index b46305a..a1dc1f4 100644 --- a/aisbom/cli.py +++ b/aisbom/cli.py @@ -28,7 +28,9 @@ import uuid from .version_check import check_latest_version from . import loop_state +from . import offline from . import osv +from . import pypi from . import telemetry import requests @@ -67,6 +69,9 @@ def main( AISBOM_NO_OSV=1 Same as `scan --no-osv`: never query OSV for requirements.txt CVEs when emitting VEX. + + AISBOM_OFFLINE=1 Same as `--offline`: no network access of any + kind (PyPI, OSV, telemetry, update check). """ # Order matters: --version wins over the no-args panel so that # `aisbom --version` is short and scriptable. @@ -140,6 +145,50 @@ def _classify_target(target: str) -> str: return "local" +def _refuse_remote_target_offline(target: str) -> None: + """A remote target has to be downloaded, which --offline forbids (#129). + + Refused before scanning rather than left to fail as a fetch error: the + user asked for no network, and a fetch attempt would be exactly that. + """ + if offline.is_offline() and _classify_target(target) != "local": + console.print( + f"[bold red]✖ Cannot scan {target} with --offline[/bold red] — a " + "remote target has to be downloaded. Scan a local copy instead, or " + "drop --offline (and unset AISBOM_OFFLINE)." + ) + raise typer.Exit(code=1) + + +def _resolve_dependency_licenses(results: dict, out: Console) -> None: + """Fill in PyPI-declared licenses for exact requirements.txt pins (#129). + + Default-on, best-effort by contract: a failure costs those dependencies + their license and prints why, and nothing else about the run changes. + """ + dependencies = results.get("dependencies") or [] + if offline.is_offline() or not any(d.get("pinned") for d in dependencies): + return + with out.status("[cyan]Resolving dependency licenses from PyPI...[/cyan]"): + lookup = pypi.resolve_dependency_licenses(dependencies) + if lookup.error: + err_console.print( + f"[yellow]⚠ {lookup.error}; those dependencies carry no license " + "this run.[/yellow]" + ) + line = ( + f"[dim]PyPI: resolved licenses for {lookup.resolved} of " + f"{lookup.queried} pinned dependenc{'y' if lookup.queried == 1 else 'ies'}" + ) + if lookup.skipped_unpinned: + line += ( + f"; {lookup.skipped_unpinned} unpinned dependenc" + f"{'y' if lookup.skipped_unpinned == 1 else 'ies'} skipped " + "(only exact == pins are looked up)" + ) + out.print(line + ".[/dim]") + + def _classify_http_status(exc: BaseException) -> str: """Bucket a fetch exception into a low-cardinality status label. @@ -617,16 +666,36 @@ def scan( ), rich_help_panel="Advanced Options", ), + offline_mode: bool = typer.Option( + False, + "--offline", + help=( + "Make no network access of any kind: no PyPI license lookup, no " + "OSV lookup, no telemetry, no update check. Remote targets and " + "--share are refused. AISBOM_OFFLINE=1 does the same." + ), + ), ): """ Deep Introspection Scan: Analyzes binary headers and dependency manifests. """ + offline.enable(offline_mode) + # Start background check - t = threading.Thread(target=run_version_check_wrapper, daemon=True) - t.start() + if not offline.is_offline(): + t = threading.Thread(target=run_version_check_wrapper, daemon=True) + t.start() console.print(Panel.fit(f"🚀 [bold cyan]AIsbom[/bold cyan] Scanning: [underline]{target}[/underline]")) + _refuse_remote_target_offline(target) + if share and offline.is_offline(): + console.print( + "[bold red]✖ --share cannot be combined with --offline[/bold red] — " + "it uploads the SBOM to aisbom.io." + ) + raise typer.Exit(code=1) + # Rejected up front rather than after the scan: the option is only read at # emission time, so an unrecognised value used to fall through to 2.3 and # hand back a document the user did not ask for, after a full scan's wait. @@ -858,6 +927,12 @@ def _risk_score(label: str) -> int: if results['dependencies']: console.print(f"\n📦 Found [bold]{len(results['dependencies'])}[/bold] Python libraries.") + # Only the outputs that carry a dependency license pay for the lookup: + # Markdown has no dependency table and SPDX 3.0 has no license model yet. + if format == OutputFormat.JSON or ( + format == OutputFormat.SPDX and spdx_version == "2.3" + ): + _resolve_dependency_licenses(results, console) # Unusable targets (#125): the path was missing or nothing could scan it, # so nothing was examined. Printed to stderr like fetch failures — it is a @@ -908,7 +983,9 @@ def _risk_score(label: str) -> int: vex_format=vex_format, baseline_path=vex_baseline, schema_version=schema_version, - osv_enabled=not (no_osv or osv.disabled_by_env()), + osv_enabled=not ( + no_osv or osv.disabled_by_env() or offline.is_offline() + ), ) has_content = bool(results.get('artifacts') or results.get('dependencies')) @@ -1009,11 +1086,12 @@ def info(): # Phase 4 help-pass: surface telemetry state in `info` so users have one # canonical place to confirm whether events are firing on their machine. - telemetry_state = ( - "opted out via AISBOM_NO_TELEMETRY" - if os.getenv("AISBOM_NO_TELEMETRY") - else "enabled (set AISBOM_NO_TELEMETRY=1 to disable)" - ) + if os.getenv("AISBOM_NO_TELEMETRY"): + telemetry_state = "opted out via AISBOM_NO_TELEMETRY" + elif offline.is_offline(): + telemetry_state = "disabled by AISBOM_OFFLINE (no network access)" + else: + telemetry_state = "enabled (set AISBOM_NO_TELEMETRY=1 to disable)" console.print(Panel( f"[bold cyan]AI SBOM[/bold cyan]: AI Software Bill of Materials - The Supply Chain for Artificial Intelligence\n" @@ -1255,6 +1333,15 @@ def score( strict: bool = typer.Option( False, help="Use strict allowlisting mode when TARGET is a scan target." ), + offline_mode: bool = typer.Option( + False, + "--offline", + help=( + "Make no network access of any kind (no PyPI license lookup, no " + "telemetry, no update check); remote targets are refused. " + "AISBOM_OFFLINE=1 does the same." + ), + ), ): """ Grade an AIBOM for completeness and quality. @@ -1264,8 +1351,11 @@ def score( and document provenance — and names the specific gaps behind each one. Use --fail-under to gate CI on the result. """ - t = threading.Thread(target=run_version_check_wrapper, daemon=True) - t.start() + offline.enable(offline_mode) + if not offline.is_offline(): + t = threading.Thread(target=run_version_check_wrapper, daemon=True) + t.start() + _refuse_remote_target_offline(target) scan_id = uuid.uuid4().hex telemetry_threads: list[threading.Thread | None] = [_maybe_emit_install_event()] @@ -1326,6 +1416,9 @@ def score( _flush_telemetry_threads(telemetry_threads) raise typer.Exit(code=1) + # The same enrichment `scan` applies, so the grade still describes the + # file a scan would have written (#129). + _resolve_dependency_licenses(results, progress) doc = json.loads(build_cyclonedx_json(results, "1.7")) else: console.print( diff --git a/aisbom/cyclonedx_gen.py b/aisbom/cyclonedx_gen.py index 043dd34..70a0483 100644 --- a/aisbom/cyclonedx_gen.py +++ b/aisbom/cyclonedx_gen.py @@ -22,6 +22,7 @@ from cyclonedx.model.component import Component, ComponentType from cyclonedx.output.json import JsonV1Dot5, JsonV1Dot6, JsonV1Dot7 +from . import pypi from .modelcard import bom_ref_for, dependency_bom_ref, inject_model_cards from .properties import build_component_properties from .spdx_gen import _sha256_or_none @@ -108,14 +109,30 @@ def build_bom(results: Dict[str, Any]) -> Bom: # for nothing (#114). Omitting it is safe: `diff.SBOMDiff` already # reads `component.get("version", "unknown")`, so an absent field and # the literal string compare equal and no drift is reported. - bom.components.add(Component( + lib = Component( name=dep["name"], version=None if version == "unknown" else version, type=ComponentType.LIBRARY, # Stable for the same reason as the model components: a CVE-keyed # VEX statement (#128) addresses this component by bom-ref. bom_ref=dependency_bom_ref(dep_index, dep), - )) + ) + # The license PyPI declares for this exact pin (#129). It goes straight + # into `licenses[]`: for a library nothing downstream turns that field + # into a verdict (the platform's risk and drift counts read models + # only, and `aisbom diff` reads the description), so third-party tools + # get the standard field. The source property says it is a registry + # declaration rather than something read out of the artifact. + if dep.get("license"): + lib.licenses.add( + lf.make_from_string(dep["license"]) if dep.get("license_is_spdx") + else lf.make_with_name(dep["license"]) + ) + lib.properties.add(Property( + name=pypi.LICENSE_SOURCE_PROPERTY, + value=dep.get("license_source") or pypi.LICENSE_SOURCE, + )) + bom.components.add(lib) return bom diff --git a/aisbom/offline.py b/aisbom/offline.py new file mode 100644 index 0000000..661ae82 --- /dev/null +++ b/aisbom/offline.py @@ -0,0 +1,32 @@ +"""The single switch for "this run must not touch the network" (#129). + +`--offline` on `scan`/`score`, or ``AISBOM_OFFLINE=1`` for any command. Every +code path that can reach a server consults :func:`is_offline`: the PyPI +license lookup, the OSV lookup, telemetry and the update check, while remote +targets and ``--share`` are refused outright because they *are* network +operations. A narrower flag named "offline" that still phoned home would +mislead exactly the air-gapped users it exists for. + +The flag is process state because telemetry and the update check are called +from many places; each command that accepts ``--offline`` sets it on entry, +so one invocation's choice never carries into the next. +""" + +from __future__ import annotations + +import os +from typing import Mapping + +ENV_VAR = "AISBOM_OFFLINE" + +_forced = False + + +def enable(flag: bool) -> None: + """Set this invocation's ``--offline`` choice (False clears a prior one).""" + global _forced + _forced = bool(flag) + + +def is_offline(environ: Mapping[str, str] = os.environ) -> bool: + return _forced or bool(environ.get(ENV_VAR)) diff --git a/aisbom/pypi.py b/aisbom/pypi.py new file mode 100644 index 0000000..c9a0e91 --- /dev/null +++ b/aisbom/pypi.py @@ -0,0 +1,483 @@ +"""PyPI license resolution for pinned requirements.txt dependencies (#129). + +Library components parsed from ``requirements.txt`` carried a name and a +version and nothing else, so every SBOM lost most of the completeness grade's +Licenses dimension (#114) for information PyPI publishes. This module asks +PyPI's JSON API what each exact pin declares and records it on the dependency, +where the CycloneDX and SPDX 2.3 generators pick it up. + +Contract +-------- + +**Enrichment, never the scan.** A PyPI outage, a rate limit, an unknown +package, a malformed body or an exhausted time budget costs that dependency its +license and nothing else. Nothing here raises into the CLI or changes an exit +code — the same asymmetry as the HF model-card fetch (#111) and the OSV lookup +(#128). Unlike OSV, a *partial* answer is fine: a license resolved for torch +says nothing about transformers, whereas a partial CVE list reads as complete. + +**A declaration, never a verdict.** The result is written to the dependency's +``license`` and never to ``legal_status``. That field drives a compliance +judgement (the CLI's LEGAL RISK label, the platform's ``license_issue_count``), +and deriving it from registry metadata nobody reviewed would be a silent +verdict change — the same reason Hugging Face card licenses stay out of it +(#111). + +**Only exact pins are looked up.** ``torch>=2.0`` does not say which release +is installed, and licenses do change between releases. + +**Nothing is guessed.** Sources are tried from most to least precise: the PEP +639 ``license_expression``, then a valid SPDX id or expression in ``license``, +then a short list of unambiguous free-text spellings, then a classifier that +names exactly one license, then any other short declaration kept verbatim as a +license *name*. A license text pasted into the field (numpy ships 46KB of it) +and a classifier that does not say which license it means (``BSD License``) +resolve to nothing. + +**Cached on disk.** A CI job scanning the same ``requirements.txt`` on every +push must not hit PyPI every time. Answers live in +``~/.aisbom/pypi_license_cache.json``: a resolved license for +:data:`RESOLVED_TTL_SECONDS` (a published release's metadata does not change), +an unknown package or undeclared license for :data:`UNRESOLVED_TTL_SECONDS`. +Failures are never cached. + +PyInstaller constraint: ``requests``, ``packaging`` and ``cyclonedx`` only, all +already bundled. +""" + +from __future__ import annotations + +import json +import re +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple +from urllib.parse import quote + +import requests +from cyclonedx import spdx +from packaging.utils import canonicalize_name + +from .spdx_gen import _tool_version + +PYPI_API = "https://pypi.org/pypi" +LICENSE_SOURCE = "pypi" +# Names where a dependency's license came from, so a consumer can tell a +# registry declaration apart from one AIsbom read out of the artifact itself. +LICENSE_SOURCE_PROPERTY = "aisbom:license:source" + +CACHE_FILENAME = "pypi_license_cache.json" +RESOLVED_TTL_SECONDS = 30 * 24 * 60 * 60 +UNRESOLVED_TTL_SECONDS = 24 * 60 * 60 +_CACHE_SCHEMA = 1 + +REQUEST_TIMEOUT_SECONDS = 5 +LOOKUP_BUDGET_SECONDS = 15 +_FETCH_WORKERS = 8 +_ATTEMPTS = 3 +_BACKOFF_SECONDS = 0.5 +_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504}) + +# Free text longer than this is a license *text*, not a declaration of one. +_MAX_NAME_LENGTH = 64 +_MAX_EXPRESSION_LENGTH = 512 + +_DEFAULT_CACHE_DIR = object() + + +class PyPIUnavailable(Exception): + """A lookup that cannot produce an answer for this dependency.""" + + +@dataclass(frozen=True) +class ResolvedLicense: + value: str + # True when ``value`` is a valid SPDX id or expression. SPDX 2.3 can only + # carry those in ``licenseDeclared``; anything else stays a CycloneDX name. + is_spdx: bool + + +@dataclass +class LicenseLookupResult: + queried: int = 0 + resolved: int = 0 + failed: int = 0 + skipped_unpinned: int = 0 + error: Optional[str] = None + + +# -------------------------------------------------------------------------- +# Normalisation +# -------------------------------------------------------------------------- + +# Free-text spellings that name exactly one SPDX license. Keys are compared +# after `_spelling_key` (casefolded, commas dropped, whitespace collapsed). +# Deliberately absent: bare "BSD", "GPL", "Apache" — each leaves the version or +# variant unsaid, so they are kept as names rather than promoted to an id. +_SPELLINGS: Dict[str, str] = { + "apache 2.0 license": "Apache-2.0", + "apache 2.0": "Apache-2.0", + "apache license 2.0": "Apache-2.0", + "apache license version 2.0": "Apache-2.0", + "apache license v2.0": "Apache-2.0", + "apache software license 2.0": "Apache-2.0", + "apache-2.0 license": "Apache-2.0", + "mit license": "MIT", + "the mit license": "MIT", + "bsd 3-clause license": "BSD-3-Clause", + "bsd 3-clause": "BSD-3-Clause", + "bsd-3-clause license": "BSD-3-Clause", + "3-clause bsd license": "BSD-3-Clause", + "new bsd license": "BSD-3-Clause", + "bsd 2-clause license": "BSD-2-Clause", + "bsd-2-clause license": "BSD-2-Clause", + "2-clause bsd license": "BSD-2-Clause", + "simplified bsd license": "BSD-2-Clause", + "isc license": "ISC", + "mozilla public license 2.0": "MPL-2.0", + "the unlicense": "Unlicense", +} + +# Trove classifiers that name exactly one SPDX license. `BSD License`, +# `Apache Software License` and the unversioned GPL/LGPL classifiers are absent +# on purpose: they do not say which license they mean. +_CLASSIFIERS: Dict[str, str] = { + "License :: OSI Approved :: MIT License": "MIT", + "License :: OSI Approved :: MIT No Attribution License (MIT-0)": "MIT-0", + "License :: OSI Approved :: ISC License (ISCL)": "ISC", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)": "MPL-2.0", + "License :: OSI Approved :: GNU General Public License v2 (GPLv2)": "GPL-2.0-only", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)": "GPL-2.0-or-later", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)": "GPL-3.0-only", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)": "GPL-3.0-or-later", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)": "LGPL-3.0-only", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)": "LGPL-3.0-or-later", + "License :: OSI Approved :: GNU Affero General Public License v3": "AGPL-3.0-only", + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)": "AGPL-3.0-or-later", + "License :: OSI Approved :: Eclipse Public License 2.0 (EPL-2.0)": "EPL-2.0", + "License :: OSI Approved :: Boost Software License 1.0 (BSL-1.0)": "BSL-1.0", + "License :: OSI Approved :: The Unlicense (Unlicense)": "Unlicense", + "License :: OSI Approved :: zlib/libpng License": "Zlib", + "License :: OSI Approved :: Universal Permissive License (UPL)": "UPL-1.0", + "License :: OSI Approved :: Python Software Foundation License": "PSF-2.0", + "License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication": "CC0-1.0", +} + +_PLACEHOLDERS = frozenset({ + "unknown", "none", "null", "n/a", "na", "other", "license", "licence", +}) +_LICENSE_FILENAME = re.compile(r"^licen[cs]e(\.[a-z0-9]+)?$", re.IGNORECASE) + + +def _single_line(value: Any, limit: int) -> Optional[str]: + if not isinstance(value, str): + return None + text = value.strip() + if not text or "\n" in text or len(text) > limit: + return None + return text + + +def _as_spdx(value: Any, limit: int) -> Optional[str]: + """``value`` as a canonical SPDX id or a valid expression, else None.""" + text = _single_line(value, limit) + if text is None: + return None + canonical = spdx.fixup_id(text) + if canonical: + return canonical + return text if spdx.is_expression(text) else None + + +def _spelling_key(text: str) -> str: + return " ".join(text.replace(",", " ").casefold().split()) + + +def _as_name(value: Any) -> Optional[str]: + """A short declaration worth keeping verbatim, or None for placeholders.""" + text = _single_line(value, _MAX_NAME_LENGTH) + if text is None: + return None + folded = text.casefold() + if ( + folded in _PLACEHOLDERS + or folded.startswith(("see ", "copyright")) + or "://" in folded + or _LICENSE_FILENAME.match(text) + ): + return None + return text + + +def normalize_license(info: Any) -> Optional[ResolvedLicense]: + """The license a PyPI ``info`` object declares, or None. Never raises.""" + if not isinstance(info, Mapping): + return None + + expression = _as_spdx(info.get("license_expression"), _MAX_EXPRESSION_LENGTH) + if expression: + return ResolvedLicense(expression, is_spdx=True) + + field = info.get("license") + as_spdx = _as_spdx(field, _MAX_EXPRESSION_LENGTH) + if as_spdx: + return ResolvedLicense(as_spdx, is_spdx=True) + + name = _as_name(field) + if name and _spelling_key(name) in _SPELLINGS: + return ResolvedLicense(_SPELLINGS[_spelling_key(name)], is_spdx=True) + + classifiers = info.get("classifiers") + if isinstance(classifiers, list): + mapped = {_CLASSIFIERS[c] for c in classifiers if c in _CLASSIFIERS} + # Two license classifiers could mean a choice, a combination or a stale + # leftover. Joining them into an expression would assert one of those. + if len(mapped) == 1: + return ResolvedLicense(mapped.pop(), is_spdx=True) + + if name: + return ResolvedLicense(name, is_spdx=False) + return None + + +# -------------------------------------------------------------------------- +# Cache +# -------------------------------------------------------------------------- + +class _Cache: + def __init__(self, directory: Optional[Path], now: float): + self.path = directory / CACHE_FILENAME if directory else None + self.now = now + self.entries: Dict[str, Dict[str, Any]] = {} + # Only a run that fetched something writes: a CI job whose every pin + # is cached should not rewrite a shared home directory's file. + self.dirty = False + self._load() + + def _load(self) -> None: + if self.path is None: + return + try: + data = json.loads(self.path.read_text()) + except (OSError, ValueError): + return + if not isinstance(data, dict) or data.get("schema") != _CACHE_SCHEMA: + return + if isinstance(data.get("entries"), dict): + self.entries = data["entries"] + + def _fresh(self, entry: Any) -> bool: + try: + ttl = RESOLVED_TTL_SECONDS if entry.get("license") else UNRESOLVED_TTL_SECONDS + return self.now - float(entry["fetched_at"]) < ttl + except (AttributeError, TypeError, KeyError, ValueError): + return False + + def get(self, key: str) -> Tuple[bool, Optional[ResolvedLicense]]: + """(hit, license). A hit with no license is a cached unknown.""" + entry = self.entries.get(key) + if not self._fresh(entry): + return False, None + value = entry.get("license") + if value is None: + return True, None + if isinstance(value, str) and isinstance(entry.get("is_spdx"), bool): + return True, ResolvedLicense(value, entry["is_spdx"]) + return False, None + + def put(self, key: str, resolved: Optional[ResolvedLicense]) -> None: + self.entries[key] = { + "fetched_at": self.now, + "license": resolved.value if resolved else None, + "is_spdx": resolved.is_spdx if resolved else False, + } + self.dirty = True + + def save(self) -> None: + """Write-tmp-then-rename, dropping expired entries. Never raises.""" + if self.path is None or not self.dirty: + return + payload = { + "schema": _CACHE_SCHEMA, + "entries": {k: v for k, v in self.entries.items() if self._fresh(v)}, + } + tmp = self.path.with_suffix(".json.tmp") + try: + tmp.write_text(json.dumps(payload, separators=(",", ":"))) + tmp.replace(self.path) + except OSError: + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + + +def _default_session() -> Any: + """The HTTP client used when none is injected. A seam the test suite stubs.""" + session = requests.Session() + adapter = requests.adapters.HTTPAdapter( + pool_connections=1, pool_maxsize=_FETCH_WORKERS + ) + session.mount("https://", adapter) + return session + + +def _default_cache_dir() -> Optional[Path]: + # Imported lazily so tests that stub telemetry's config dir apply here too. + from . import telemetry + + return telemetry.get_config_dir() + + +# -------------------------------------------------------------------------- +# Network +# -------------------------------------------------------------------------- + +class _Client: + def __init__(self, session: Any, budget_seconds: float): + self.session = session + self.deadline = time.monotonic() + budget_seconds + self.headers = {"User-Agent": f"aisbom-cli/{_tool_version()}"} + + def _timeout(self) -> float: + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise PyPIUnavailable("PyPI lookup exceeded its time budget") + return min(REQUEST_TIMEOUT_SECONDS, remaining) + + def _pause(self, seconds: float) -> None: + if self.deadline - time.monotonic() <= seconds: + raise PyPIUnavailable("PyPI lookup exceeded its time budget") + time.sleep(seconds) + + def release_info(self, name: str, version: str) -> Optional[Dict[str, Any]]: + """The ``info`` object for one release, or None when PyPI has no such + release. Retries transient failures only; raises when there is no + answer to give.""" + url = f"{PYPI_API}/{quote(name, safe='')}/{quote(version, safe='')}/json" + for attempt in range(_ATTEMPTS): + last = attempt == _ATTEMPTS - 1 + try: + response = self.session.get( + url, timeout=self._timeout(), headers=self.headers + ) + except (requests.ConnectionError, requests.Timeout): + if last: + raise + else: + if response.status_code == 404: + return None + if response.status_code in _RETRYABLE_STATUS and not last: + pass + else: + response.raise_for_status() + try: + payload = response.json() + except ValueError as exc: + raise PyPIUnavailable( + f"PyPI returned a malformed response for {name}" + ) from exc + info = payload.get("info") if isinstance(payload, dict) else None + if not isinstance(info, dict): + raise PyPIUnavailable( + f"PyPI returned a malformed response for {name}" + ) + return info + self._pause(_BACKOFF_SECONDS * (attempt + 1)) + raise PyPIUnavailable("PyPI retries exhausted") # pragma: no cover - loop returns or raises + + +def _describe(exc: BaseException) -> str: + if isinstance(exc, PyPIUnavailable): + return str(exc) + if isinstance(exc, requests.RequestException): + return f"PyPI request failed ({type(exc).__name__})" + return f"PyPI lookup failed ({type(exc).__name__})" + + +def resolve_dependency_licenses( + dependencies: Sequence[Dict[str, Any]], + *, + session: Any = None, + cache_dir: Any = _DEFAULT_CACHE_DIR, + now: Optional[float] = None, + budget_seconds: float = LOOKUP_BUDGET_SECONDS, +) -> LicenseLookupResult: + """Record PyPI-declared licenses on a scan's dependencies. Never raises. + + Each resolved dependency gains ``license``, ``license_is_spdx`` and + ``license_source``; every other dependency is left exactly as it was. + """ + result = LicenseLookupResult() + targets: List[Tuple[Dict[str, Any], str, str]] = [] + for dep in dependencies: + if not dep.get("pinned"): + result.skipped_unpinned += 1 + continue + targets.append((dep, canonicalize_name(str(dep["name"])), str(dep["version"]))) + result.queried = len(targets) + if not targets: + return result + + directory = _default_cache_dir() if cache_dir is _DEFAULT_CACHE_DIR else cache_dir + cache = _Cache(Path(directory) if directory else None, + time.time() if now is None else now) + + answers: Dict[Tuple[str, str], Optional[ResolvedLicense]] = {} + failures: Dict[Tuple[str, str], str] = {} + try: + uncached = [] + for pin in dict.fromkeys((name, version) for _, name, version in targets): + hit, resolved = cache.get(f"{pin[0]}=={pin[1]}") + if hit: + answers[pin] = resolved + else: + uncached.append(pin) + + if uncached: + client = _Client( + session if session is not None else _default_session(), budget_seconds + ) + + def fetch(pin: Tuple[str, str]) -> Optional[ResolvedLicense]: + return normalize_license(client.release_info(*pin)) + + with ThreadPoolExecutor(max_workers=min(_FETCH_WORKERS, len(uncached))) as pool: + futures = {pin: pool.submit(fetch, pin) for pin in uncached} + for pin, future in futures.items(): + try: + answers[pin] = future.result() + except Exception as exc: # noqa: BLE001 - one package, not the scan + failures[pin] = _describe(exc) + continue + cache.put(f"{pin[0]}=={pin[1]}", answers[pin]) + except Exception as exc: # noqa: BLE001 - enrichment must never break a scan + for _, name, version in targets: + if (name, version) not in answers: + failures.setdefault((name, version), _describe(exc)) + finally: + cache.save() + + for dep, name, version in targets: + pin = (name, version) + if pin in failures: + result.failed += 1 + continue + resolved = answers.get(pin) + if resolved is None: + continue + dep["license"] = resolved.value + dep["license_is_spdx"] = resolved.is_spdx + dep["license_source"] = LICENSE_SOURCE + result.resolved += 1 + + if failures: + reason = next(iter(failures.values())) + result.error = ( + f"PyPI license lookup failed for {result.failed} of " + f"{result.queried} pinned dependenc" + f"{'y' if result.queried == 1 else 'ies'} ({reason})" + ) + return result diff --git a/aisbom/score.py b/aisbom/score.py index 8d36b46..359a345 100644 --- a/aisbom/score.py +++ b/aisbom/score.py @@ -492,8 +492,9 @@ def _score_licenses(comps: List[Dict[str, Any]]) -> DimensionScore: return DimensionScore( "licenses", label, 15, _pct(licensed, len(comps)), gaps, remediation=( - "Declare a license on each component listed under --verbose — " - "dependency licenses are not yet resolved automatically" + "Declare a license on each component listed under --verbose. " + "Dependency licenses are resolved from PyPI for exact == pins only; " + "pin a requirement to have its license filled in" ) if missing else None, summary=(f"{missing} of {len(comps)} component(s) declare no license" if missing else None), diff --git a/aisbom/spdx_gen.py b/aisbom/spdx_gen.py index 0edb360..5fb7c61 100644 --- a/aisbom/spdx_gen.py +++ b/aisbom/spdx_gen.py @@ -35,6 +35,24 @@ _SPDX_ID_SAFE_RE = re.compile(r"[^a-zA-Z0-9.\-]") +def _declared_license(dep: Dict[str, Any]): + """The PyPI-declared license of a dependency (#129), or NOASSERTION. + + Declared, never concluded: the value is what the package's metadata says, + and nobody has reviewed it. Only SPDX expressions are carried — SPDX 2.3 + has no field for a free-text name, and minting a ``LicenseRef-`` for one + would assert an identity the package author never gave it. + """ + if not (dep.get("license") and dep.get("license_is_spdx")): + return SpdxNoAssertion() + from spdx_tools.common.spdx_licensing import spdx_licensing + + try: + return spdx_licensing.parse(dep["license"], validate=True, strict=True) + except Exception: # noqa: BLE001 - a bad value costs the field, not the document + return SpdxNoAssertion() + + def _tool_version() -> str: """Version of the running CLI, for the document's ``creators`` field. @@ -197,7 +215,7 @@ def _process_dependency(self, dep: Dict, doc_spdx_id: str): download_location=SpdxNoAssertion(), files_analyzed=False, license_concluded=SpdxNoAssertion(), - license_declared=SpdxNoAssertion(), + license_declared=_declared_license(dep), copyright_text=SpdxNoAssertion() ) diff --git a/aisbom/telemetry.py b/aisbom/telemetry.py index 8c41896..c20b4e6 100644 --- a/aisbom/telemetry.py +++ b/aisbom/telemetry.py @@ -33,6 +33,8 @@ import requests +from . import offline + TELEMETRY_ENDPOINT = "https://api.aisbom.io/v1/telemetry" POST_TIMEOUT_SEC = 3.0 CONFIG_SCHEMA_VERSION = 1 @@ -54,6 +56,9 @@ def _telemetry_disabled() -> bool: """All-paths short-circuit. Opt-out always wins; default state is enabled.""" if os.getenv("AISBOM_NO_TELEMETRY"): return True + # `--offline` / AISBOM_OFFLINE promises no network at all (#129). + if offline.is_offline(): + return True return False diff --git a/aisbom/version_check.py b/aisbom/version_check.py index c1e492d..d62b1a9 100644 --- a/aisbom/version_check.py +++ b/aisbom/version_check.py @@ -4,6 +4,8 @@ import requests from packaging.version import parse as parse_version +from . import offline + API_URL = "https://api.aisbom.io/v1/version?utm_source=cli&utm_medium=terminal" def check_latest_version() -> str | None: @@ -13,7 +15,7 @@ def check_latest_version() -> str | None: Respects AISBOM_NO_TELEMETRY env var. """ # 1. Privacy Check - if os.getenv("AISBOM_NO_TELEMETRY"): + if os.getenv("AISBOM_NO_TELEMETRY") or offline.is_offline(): return None try: diff --git a/docs/air-gapped-guide.md b/docs/air-gapped-guide.md index 77f7cfa..81260e4 100644 --- a/docs/air-gapped-guide.md +++ b/docs/air-gapped-guide.md @@ -77,7 +77,13 @@ The tool produces two outputs: 2. **SBOM Report (`sbom.json`):** A CycloneDX JSON file generated in the working directory. * This file is **static plain text**. It is safe to egress back to "Zone A" for ingestion into your central vulnerability dashboard. -> **VEX in Zone B:** `scan --vex` normally looks up `requirements.txt` pins in the public OSV database. On an air-gapped host that lookup fails and the scan carries on without it — you get a warning, and the VEX documents contain the model finding statements but no dependency CVE statements. To skip the attempt entirely, pass `--no-osv` (or set `AISBOM_NO_OSV=1`). +> **Use `--offline` in Zone B.** A normal scan makes a few network requests: it looks up exact `requirements.txt` pins on PyPI to fill in dependency licenses, `--vex` looks them up in the public OSV database, and the CLI sends anonymous telemetry and checks for updates. On an air-gapped host each of those fails and the scan carries on without it, but the attempts still happen. Pass `--offline` (or set `AISBOM_OFFLINE=1`) and none of them is attempted: +> +> ```bash +> ./aisbom-linux-amd64 scan /path/to/model_directory --offline +> ``` +> +> The SBOM carries every model finding as usual. Dependency components have no licenses, and `--vex` documents contain the model finding statements but no dependency CVE statements. `hf://` and `https://` targets are refused, since they can only be scanned by downloading them. --- diff --git a/tests/conftest.py b/tests/conftest.py index 1b72928..067a676 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -61,6 +61,33 @@ def _stub_osv_network(monkeypatch): monkeypatch.setattr("aisbom.osv._default_session", lambda: _OfflineOSV()) +class _OfflinePyPI: + """Stands in for the HTTP session inside aisbom.pypi: every call fails.""" + + def get(self, *args, **kwargs): + raise ConnectionError("PyPI is not reachable from the test suite") + + +@pytest.fixture(autouse=True) +def _stub_pypi_network(monkeypatch): + """Keep the default-on license lookup (#129) from ever reaching pypi.org. + + Same shape as `_stub_osv_network`: only the default session is replaced, + so tests that inject a fake PyPI are unaffected, and a scan that forgets + to see the documented degraded behaviour (no license) instead of a live + request. + """ + monkeypatch.setattr("aisbom.pypi._default_session", lambda: _OfflinePyPI()) + + +@pytest.fixture(autouse=True) +def _reset_offline(monkeypatch): + """`--offline` is process state; a CliRunner invocation must not hand it + to the next test.""" + monkeypatch.setattr("aisbom.offline._forced", False) + monkeypatch.delenv("AISBOM_OFFLINE", raising=False) + + @pytest.fixture(autouse=True) def _stub_version_check(monkeypatch): """Auto-stub the background update check for every test. diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index 8326235..bb147d2 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -19,6 +19,11 @@ def _aisbom_executable() -> str: def _run_cli(args, cwd: Path, env=None): env_vars = os.environ.copy() + # A subprocess is out of reach of every conftest monkeypatch, so without + # this a real `scan` here would query pypi.org for its requirements pins, + # post telemetry and run the update check, and write ~/.aisbom on the + # machine running the suite. Callers that need the network can override. + env_vars["AISBOM_OFFLINE"] = "1" if env: env_vars.update(env) result = subprocess.run( diff --git a/tests/test_cyclonedx_gen.py b/tests/test_cyclonedx_gen.py index a1d1026..1dc7490 100644 --- a/tests/test_cyclonedx_gen.py +++ b/tests/test_cyclonedx_gen.py @@ -211,3 +211,92 @@ def test_the_same_package_in_two_requirements_files_keeps_distinct_refs(): ]) refs = [c["bom-ref"] for c in doc["components"] if c["name"] == "torch"] assert sorted(refs) == ["dependency-0-torch", "dependency-1-torch"] + + +# --------------------------------------------------------------------------- +# PyPI-resolved dependency licenses (#129). +# --------------------------------------------------------------------------- + +_TORCH_EXPRESSION = ( + "Apache-2.0 AND Apache-2.0 WITH LLVM-exception AND BSD-2-Clause " + "AND BSD-3-Clause AND BSL-1.0 AND MIT" +) + + +def _resolved(name, version, license, is_spdx=True): + return {"name": name, "version": version, "pinned": True, + "license": license, "license_is_spdx": is_spdx, + "license_source": "pypi"} + + +def test_a_resolved_spdx_id_lands_in_licenses(): + doc = _doc(dependencies=[_resolved("transformers", "5.13.1", "Apache-2.0")]) + comp = _by_name(doc, "transformers") + assert comp["licenses"] == [{"license": {"id": "Apache-2.0"}}] + + +def test_a_resolved_expression_lands_in_licenses(): + doc = _doc(dependencies=[_resolved("torch", "2.13.0", _TORCH_EXPRESSION)]) + assert _by_name(doc, "torch")["licenses"] == [{"expression": _TORCH_EXPRESSION}] + + +def test_a_non_spdx_declaration_is_emitted_as_a_license_name(): + doc = _doc(dependencies=[_resolved("vendorlib", "1.0", "Proprietary", False)]) + assert _by_name(doc, "vendorlib")["licenses"] == [{"license": {"name": "Proprietary"}}] + + +def test_the_license_source_is_recorded_as_a_property(): + doc = _doc(dependencies=[_resolved("transformers", "5.13.1", "Apache-2.0")]) + assert _by_name(doc, "transformers")["properties"] == [ + {"name": "aisbom:license:source", "value": "pypi"} + ] + + +def test_an_unresolved_dependency_is_byte_identical_to_before(): + doc = _doc() + assert set(_by_name(doc, "torch")) == {"bom-ref", "name", "type", "version"} + + +@pytest.mark.parametrize("version", ["1.5", "1.6", "1.7"]) +def test_dependency_licenses_validate_strictly(version): + from cyclonedx.schema import SchemaVersion + from cyclonedx.validation.json import JsonStrictValidator + + sbom = build_cyclonedx_json(_results(dependencies=[ + _resolved("torch", "2.13.0", _TORCH_EXPRESSION), + _resolved("transformers", "5.13.1", "Apache-2.0"), + _resolved("vendorlib", "1.0", "Proprietary", False), + {"name": "numpy", "version": "unknown"}, + ]), version) + validator = JsonStrictValidator(SchemaVersion.from_version(version)) + assert validator.validate_str(sbom) is None + + +def test_resolved_dependency_licenses_raise_the_license_grade(): + from aisbom import score + + before = score.score_sbom(_doc(dependencies=[ + {"name": "torch", "version": "2.13.0"}, + ])) + after = score.score_sbom(_doc(dependencies=[ + _resolved("torch", "2.13.0", _TORCH_EXPRESSION), + ])) + lic = {d.key: d.score for d in before.dimensions}["licenses"] + lic_after = {d.key: d.score for d in after.dimensions}["licenses"] + assert lic_after > lic + + +def test_dependency_licenses_cause_no_diff_drift(tmp_path): + """`aisbom diff` reads license from the description, which libraries lack, + so upgrading the CLI must not report every dependency as changed.""" + from aisbom.diff import SBOMDiff + + old = tmp_path / "old.json" + new = tmp_path / "new.json" + old.write_text(build_cyclonedx_json(_results())) + new.write_text(build_cyclonedx_json(_results(dependencies=[ + _resolved("torch", "2.10.0", "BSD-3-Clause"), + {"name": "numpy", "version": "unknown"}, + ]))) + result = SBOMDiff(str(old), str(new)).compare() + assert (result.added, result.removed, result.changed) == ([], [], []) diff --git a/tests/test_pypi.py b/tests/test_pypi.py new file mode 100644 index 0000000..9c7c642 --- /dev/null +++ b/tests/test_pypi.py @@ -0,0 +1,505 @@ +"""PyPI license resolution for pinned requirements.txt dependencies (#129). + +pypi.org is never contacted from this suite. Every test drives a fake session +whose payloads are trimmed copies of real `/pypi///json` +responses, so the resolved, unknown, unreachable and cached paths are all +exercised deterministically and offline. +""" + +import json +import time + +import pytest +import requests + +from aisbom import pypi + +_REAL_DEFAULT_SESSION = pypi._default_session + + +@pytest.fixture(autouse=True) +def _no_retry_pause(monkeypatch): + """Retries are exercised here; their real-time backoff is not.""" + monkeypatch.setattr(pypi, "_BACKOFF_SECONDS", 0) + + +# -------------------------------------------------------------------------- +# Real PyPI `info` shapes (trimmed to the fields AIsbom reads) +# -------------------------------------------------------------------------- + +def _torch_info(): + """torch 2.13.0: a PEP 639 License-Expression and nothing else.""" + return { + "name": "torch", + "version": "2.13.0", + "license": None, + "license_expression": ( + "Apache-2.0 AND Apache-2.0 WITH LLVM-exception AND BSD-2-Clause " + "AND BSD-3-Clause AND BSL-1.0 AND MIT" + ), + "classifiers": ["Programming Language :: Python :: 3"], + } + + +def _transformers_info(): + """transformers 5.13.1: free text in `license`, no license classifier.""" + return { + "name": "transformers", + "version": "5.13.1", + "license": "Apache 2.0 License", + "license_expression": None, + "classifiers": ["Intended Audience :: Developers"], + } + + +def _numpy_info(): + """numpy 1.26.4: the whole BSD text in `license`, an ambiguous classifier.""" + return { + "name": "numpy", + "version": "1.26.4", + "license": ( + "Copyright (c) 2005-2023, NumPy Developers.\nAll rights reserved.\n\n" + "Redistribution and use in source and binary forms, with or without " + "modification, are permitted provided that ...\n" * 40 + ), + "license_expression": None, + "classifiers": ["License :: OSI Approved :: BSD License"], + } + + +class FakeResponse: + def __init__(self, payload, status=200): + self._payload = payload + self.status_code = status + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(f"{self.status_code}") + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +class FakePyPI: + """A stand-in for pypi.org that records every URL requested from it.""" + + def __init__(self, infos=(), fail=None, payloads=None, statuses=None): + self.infos = {(i["name"], i["version"]): i for i in infos} + self.fail = fail + # {(name, version): raw payload} — for malformed bodies. + self.payloads = payloads or {} + # {(name, version): [status, status, ...]} — consumed per request. + self.statuses = {k: list(v) for k, v in (statuses or {}).items()} + self.gets = [] + self.headers = [] + + def get(self, url, timeout=None, headers=None): + self.gets.append(url) + self.headers.append(headers) + assert timeout is not None and timeout > 0, "every request needs a timeout" + if self.fail: + raise self.fail + # https://pypi.org/pypi///json + name, version = url.rstrip("/").split("/")[-3:-1] + key = (name, version) + queued = self.statuses.get(key) + if queued: + status = queued.pop(0) + if status >= 400: + return FakeResponse({"message": "err"}, status) + if key in self.payloads: + return FakeResponse(self.payloads[key]) + if key not in self.infos: + return FakeResponse({"message": "Not Found"}, 404) + return FakeResponse({"info": self.infos[key], "urls": []}) + + @property + def calls(self): + return len(self.gets) + + +def _dep(name, version, pinned=True): + return {"name": name, "version": version, "type": "library", "pinned": pinned} + + +def _resolve(deps, session, cache_dir=None, **kw): + return pypi.resolve_dependency_licenses( + deps, session=session, cache_dir=cache_dir, **kw + ) + + +# -------------------------------------------------------------------------- +# Normalisation — what counts as a declared license +# -------------------------------------------------------------------------- + +def test_pep639_license_expression_is_used_verbatim(): + assert pypi.normalize_license(_torch_info()) == pypi.ResolvedLicense( + _torch_info()["license_expression"], is_spdx=True + ) + + +def test_a_spdx_id_in_the_license_field_is_canonicalised(): + info = {"license": "mit", "classifiers": []} + assert pypi.normalize_license(info) == pypi.ResolvedLicense("MIT", is_spdx=True) + + +def test_a_spdx_expression_in_the_license_field_is_accepted(): + info = {"license": "Apache-2.0 OR MIT", "classifiers": []} + assert pypi.normalize_license(info) == pypi.ResolvedLicense( + "Apache-2.0 OR MIT", is_spdx=True + ) + + +@pytest.mark.parametrize("text, spdx", [ + ("Apache 2.0 License", "Apache-2.0"), + ("Apache License 2.0", "Apache-2.0"), + ("Apache License, Version 2.0", "Apache-2.0"), + ("MIT License", "MIT"), + ("BSD 3-Clause License", "BSD-3-Clause"), +]) +def test_unambiguous_common_spellings_map_to_spdx(text, spdx): + info = {"license": text, "classifiers": []} + assert pypi.normalize_license(info) == pypi.ResolvedLicense(spdx, is_spdx=True) + + +def test_transformers_free_text_resolves_to_apache(): + assert pypi.normalize_license(_transformers_info()) == pypi.ResolvedLicense( + "Apache-2.0", is_spdx=True + ) + + +def test_an_unambiguous_classifier_is_used_when_the_field_is_empty(): + info = {"license": "", "classifiers": ["License :: OSI Approved :: MIT License"]} + assert pypi.normalize_license(info) == pypi.ResolvedLicense("MIT", is_spdx=True) + + +@pytest.mark.parametrize("classifier", [ + "License :: OSI Approved :: BSD License", + "License :: OSI Approved :: Apache Software License", + "License :: OSI Approved :: GNU General Public License (GPL)", + "License :: Other/Proprietary License", +]) +def test_ambiguous_classifiers_resolve_nothing(classifier): + info = {"license": None, "classifiers": [classifier]} + assert pypi.normalize_license(info) is None + + +def test_two_different_classifiers_are_not_guessed_into_an_expression(): + info = {"license": None, "classifiers": [ + "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: ISC License (ISCL)", + ]} + assert pypi.normalize_license(info) is None + + +def test_a_license_text_blob_is_never_emitted(): + # numpy: the BSD text itself, plus the classifier that does not say which BSD. + assert pypi.normalize_license(_numpy_info()) is None + + +def test_a_short_non_spdx_declaration_is_kept_as_a_name(): + info = {"license": "Proprietary", "classifiers": []} + assert pypi.normalize_license(info) == pypi.ResolvedLicense( + "Proprietary", is_spdx=False + ) + + +def test_a_spdx_classifier_beats_a_free_text_name(): + info = {"license": "Dual licensed", + "classifiers": ["License :: OSI Approved :: MIT License"]} + assert pypi.normalize_license(info) == pypi.ResolvedLicense("MIT", is_spdx=True) + + +@pytest.mark.parametrize("junk", [ + "UNKNOWN", "unknown", "None", "n/a", "", " ", "LICENSE", "LICENSE.txt", + "See LICENSE file", "https://example.com/license", "Copyright 2020 Someone", +]) +def test_placeholders_and_pointers_resolve_nothing(junk): + assert pypi.normalize_license({"license": junk, "classifiers": []}) is None + + +def test_an_invalid_license_expression_falls_through_to_the_other_fields(): + info = {"license_expression": "MIT OR", "license": "Apache-2.0", "classifiers": []} + assert pypi.normalize_license(info) == pypi.ResolvedLicense( + "Apache-2.0", is_spdx=True + ) + + +def test_every_mapping_table_value_is_a_valid_spdx_id(): + # A typo in either table would ship a non-SPDX string flagged is_spdx=True, + # which SPDX 2.3 output would then reject at write time. + from cyclonedx import spdx + + for value in {*pypi._SPELLINGS.values(), *pypi._CLASSIFIERS.values()}: + assert spdx.fixup_id(value) == value, value + + +@pytest.mark.parametrize("info", [None, [], "MIT", {"license": 42, "classifiers": "x"}]) +def test_wrongly_typed_metadata_resolves_nothing(info): + assert pypi.normalize_license(info) is None + + +# -------------------------------------------------------------------------- +# Lookup — resolved, unknown, unreachable +# -------------------------------------------------------------------------- + +def test_resolved_license_is_written_onto_the_dependency(): + deps = [_dep("torch", "2.13.0"), _dep("transformers", "5.13.1")] + result = _resolve(deps, FakePyPI([_torch_info(), _transformers_info()])) + + assert deps[0]["license"] == _torch_info()["license_expression"] + assert deps[0]["license_is_spdx"] is True + assert deps[0]["license_source"] == "pypi" + assert deps[1]["license"] == "Apache-2.0" + assert (result.queried, result.resolved, result.failed) == (2, 2, 0) + assert result.error is None + + +def test_the_request_names_the_exact_pin_and_identifies_the_tool(): + fake = FakePyPI([_torch_info()]) + _resolve([_dep("torch", "2.13.0")], fake) + assert fake.gets == ["https://pypi.org/pypi/torch/2.13.0/json"] + assert fake.headers[0]["User-Agent"].startswith("aisbom-cli/") + + +def test_names_are_canonicalised_before_lookup(): + info = {"name": "ruamel-yaml", "version": "0.18.6", "license": "MIT", + "classifiers": []} + fake = FakePyPI([info]) + deps = [_dep("Ruamel.YAML", "0.18.6")] + _resolve(deps, fake) + assert fake.gets == ["https://pypi.org/pypi/ruamel-yaml/0.18.6/json"] + assert deps[0]["license"] == "MIT" + + +def test_unknown_package_leaves_the_dependency_untouched(): + deps = [_dep("definitely-not-on-pypi", "1.0.0")] + result = _resolve(deps, FakePyPI()) + assert "license" not in deps[0] + assert (result.queried, result.resolved, result.failed) == (1, 0, 0) + assert result.error is None + + +def test_a_package_declaring_no_usable_license_is_untouched(): + deps = [_dep("numpy", "1.26.4")] + result = _resolve(deps, FakePyPI([_numpy_info()])) + assert "license" not in deps[0] + assert result.resolved == 0 and result.error is None + + +def test_unpinned_dependencies_are_skipped_and_counted(): + fake = FakePyPI([_torch_info()]) + deps = [_dep("torch", "2.0", pinned=False), _dep("torch", "2.13.0")] + result = _resolve(deps, fake) + assert "license" not in deps[0] + assert deps[1]["license"] + assert result.skipped_unpinned == 1 and result.queried == 1 + assert fake.calls == 1 + + +def test_no_pinned_dependencies_makes_no_request(): + fake = FakePyPI() + result = _resolve([_dep("torch", "2.0", pinned=False)], fake) + assert fake.calls == 0 and result.queried == 0 + + +def test_the_same_pin_listed_twice_is_fetched_once(): + fake = FakePyPI([_torch_info()]) + deps = [_dep("torch", "2.13.0"), _dep("torch", "2.13.0")] + _resolve(deps, fake) + assert fake.calls == 1 + assert deps[0]["license"] == deps[1]["license"] + + +@pytest.mark.parametrize("failure", [ + requests.ConnectionError("no route"), + requests.Timeout("slow"), +]) +def test_unreachable_pypi_degrades_to_no_license(failure): + deps = [_dep("torch", "2.13.0"), _dep("transformers", "5.13.1")] + result = _resolve(deps, FakePyPI(fail=failure)) + assert all("license" not in d for d in deps) + assert result.failed == 2 + assert result.error and "PyPI" in result.error + + +@pytest.mark.parametrize("payload", [ + ValueError("not json"), [], {"info": "nope"}, {"no_info": {}}, +]) +def test_a_malformed_body_degrades_that_dependency(payload): + fake = FakePyPI([_transformers_info()], payloads={("torch", "2.13.0"): payload}) + deps = [_dep("torch", "2.13.0"), _dep("transformers", "5.13.1")] + result = _resolve(deps, fake) + assert "license" not in deps[0] + # One package's failure costs that package, not its neighbours: a license + # on transformers says nothing about torch, unlike a partial CVE list. + assert deps[1]["license"] == "Apache-2.0" + assert result.failed == 1 and result.resolved == 1 + assert result.error + + +def test_a_transient_503_is_retried_and_resolves(): + fake = FakePyPI([_torch_info()], statuses={("torch", "2.13.0"): [503]}) + deps = [_dep("torch", "2.13.0")] + result = _resolve(deps, fake) + assert deps[0]["license"] and fake.calls == 2 and result.failed == 0 + + +@pytest.mark.parametrize("status", [429, 500, 502, 504]) +def test_other_transient_statuses_are_retried(status): + fake = FakePyPI([_torch_info()], statuses={("torch", "2.13.0"): [status]}) + deps = [_dep("torch", "2.13.0")] + _resolve(deps, fake) + assert deps[0]["license"] + + +def test_a_persistent_503_degrades_after_bounded_attempts(): + fake = FakePyPI([_torch_info()], statuses={("torch", "2.13.0"): [503] * 10}) + result = _resolve([_dep("torch", "2.13.0")], fake) + assert fake.calls == pypi._ATTEMPTS and result.failed == 1 + + +def test_a_404_is_not_retried(): + fake = FakePyPI() + _resolve([_dep("ghost", "1.0")], fake) + assert fake.calls == 1 + + +def test_an_exhausted_time_budget_degrades_without_raising(): + deps = [_dep("torch", "2.13.0")] + result = _resolve(deps, FakePyPI([_torch_info()]), budget_seconds=0) + assert "license" not in deps[0] + assert result.failed == 1 and "time budget" in result.error + + +def test_unexpected_exception_is_contained(): + class Exploding: + def get(self, *a, **kw): + raise RuntimeError("boom") + + deps = [_dep("torch", "2.13.0")] + result = _resolve(deps, Exploding()) + assert "license" not in deps[0] and result.failed == 1 and result.error + + +def test_the_default_session_pools_connections_for_every_worker(): + session = _REAL_DEFAULT_SESSION() + adapter = session.get_adapter("https://pypi.org/") + assert adapter._pool_maxsize == pypi._FETCH_WORKERS + + +def test_legal_status_is_never_written(): + deps = [_dep("torch", "2.13.0")] + _resolve(deps, FakePyPI([_torch_info()])) + assert "legal_status" not in deps[0] + + +# -------------------------------------------------------------------------- +# Cache — repeat scans must not pay the network cost again +# -------------------------------------------------------------------------- + +def test_repeat_lookup_makes_no_network_call(tmp_path): + first = FakePyPI([_torch_info()]) + _resolve([_dep("torch", "2.13.0")], first, cache_dir=tmp_path) + assert first.calls == 1 + + second = FakePyPI(fail=AssertionError("must not be called")) + deps = [_dep("torch", "2.13.0")] + result = _resolve(deps, second, cache_dir=tmp_path) + assert second.calls == 0 + assert deps[0]["license"] == _torch_info()["license_expression"] + assert result.resolved == 1 and result.error is None + + +def test_an_all_hit_lookup_does_not_rewrite_the_cache(tmp_path): + import os + + _resolve([_dep("torch", "2.13.0")], FakePyPI([_torch_info()]), cache_dir=tmp_path) + cache_file = tmp_path / pypi.CACHE_FILENAME + os.utime(cache_file, (0, 0)) + _resolve([_dep("torch", "2.13.0")], FakePyPI(), cache_dir=tmp_path) + assert cache_file.stat().st_mtime == 0 + + +def test_a_cached_unknown_is_also_reused(tmp_path): + _resolve([_dep("ghost", "1.0")], FakePyPI(), cache_dir=tmp_path) + second = FakePyPI(fail=AssertionError("must not be called")) + _resolve([_dep("ghost", "1.0")], second, cache_dir=tmp_path) + assert second.calls == 0 + + +def test_resolved_entries_outlive_unresolved_ones(tmp_path): + now = time.time() + _resolve([_dep("torch", "2.13.0"), _dep("ghost", "1.0")], + FakePyPI([_torch_info()]), cache_dir=tmp_path, now=now) + + later = now + pypi.UNRESOLVED_TTL_SECONDS + 60 + fake = FakePyPI([_torch_info()]) + _resolve([_dep("torch", "2.13.0"), _dep("ghost", "1.0")], + fake, cache_dir=tmp_path, now=later) + assert fake.gets == ["https://pypi.org/pypi/ghost/1.0/json"] + + +def test_resolved_entries_expire_eventually(tmp_path): + now = time.time() + _resolve([_dep("torch", "2.13.0")], FakePyPI([_torch_info()]), + cache_dir=tmp_path, now=now) + fake = FakePyPI([_torch_info()]) + _resolve([_dep("torch", "2.13.0")], fake, cache_dir=tmp_path, + now=now + pypi.RESOLVED_TTL_SECONDS + 60) + assert fake.calls == 1 + + +def test_a_failed_lookup_is_not_cached(tmp_path): + _resolve([_dep("torch", "2.13.0")], + FakePyPI(fail=requests.ConnectionError("down")), cache_dir=tmp_path) + fake = FakePyPI([_torch_info()]) + deps = [_dep("torch", "2.13.0")] + _resolve(deps, fake, cache_dir=tmp_path) + assert fake.calls == 1 and deps[0]["license"] + + +def test_a_corrupt_cache_file_is_ignored(tmp_path): + (tmp_path / pypi.CACHE_FILENAME).write_text("{not json") + deps = [_dep("torch", "2.13.0")] + _resolve(deps, FakePyPI([_torch_info()]), cache_dir=tmp_path) + assert deps[0]["license"] + + +def test_a_cache_file_from_another_schema_is_ignored(tmp_path): + (tmp_path / pypi.CACHE_FILENAME).write_text(json.dumps({ + "schema": 999, + "entries": {"torch==2.13.0": {"fetched_at": time.time(), + "license": "WRONG", "is_spdx": False}}, + })) + deps = [_dep("torch", "2.13.0")] + _resolve(deps, FakePyPI([_torch_info()]), cache_dir=tmp_path) + assert deps[0]["license"] != "WRONG" + + +def test_a_tampered_cache_entry_is_refetched(tmp_path): + (tmp_path / pypi.CACHE_FILENAME).write_text(json.dumps({ + "schema": pypi._CACHE_SCHEMA, + "entries": {"torch==2.13.0": {"fetched_at": time.time(), "license": 7}}, + })) + fake = FakePyPI([_torch_info()]) + deps = [_dep("torch", "2.13.0")] + _resolve(deps, fake, cache_dir=tmp_path) + assert fake.calls == 1 and deps[0]["license"] + + +def test_unwritable_cache_dir_still_resolves(tmp_path): + blocked = tmp_path / "file-not-dir" + blocked.write_text("") + deps = [_dep("torch", "2.13.0")] + result = _resolve(deps, FakePyPI([_torch_info()]), cache_dir=blocked) + assert deps[0]["license"] and result.error is None + + +def test_default_cache_dir_is_the_aisbom_config_dir(tmp_path, monkeypatch): + monkeypatch.setattr("aisbom.telemetry.get_config_dir", lambda: tmp_path) + _resolve([_dep("torch", "2.13.0")], FakePyPI([_torch_info()]), + cache_dir=pypi._DEFAULT_CACHE_DIR) + assert (tmp_path / pypi.CACHE_FILENAME).is_file() diff --git a/tests/test_pypi_cli.py b/tests/test_pypi_cli.py new file mode 100644 index 0000000..3606991 --- /dev/null +++ b/tests/test_pypi_cli.py @@ -0,0 +1,297 @@ +"""PyPI license resolution and `--offline`, end to end through the CLI (#129). + +The lookup itself is covered in test_pypi.py. These tests pin what a user +sees: licenses in the SBOM a plain `scan` writes, the same document from +`score`, a scan that survives PyPI being down, and an `--offline` run that +makes no network call of any kind — proven with the real HTTP clients and the +socket layer refusing, not with stubs that could hide a call. +""" + +import json +import socket + +import pytest +import requests as real_requests +from typer.testing import CliRunner + +from aisbom import offline, telemetry, version_check +from aisbom.cli import app +from tests.test_cli_integration import _osv_tree +from tests.test_pypi import FakePyPI, _torch_info, _transformers_info + +runner = CliRunner() + +_REAL_POST_EVENT = telemetry.post_event +_REAL_CHECK_LATEST = version_check.check_latest_version + +_REQUIREMENTS = "torch==2.13.0\ntransformers==5.13.1\nnumpy\n" + + +def _fake_pypi(monkeypatch, **kw): + fake = FakePyPI([_torch_info(), _transformers_info()], **kw) + monkeypatch.setattr("aisbom.pypi._default_session", lambda: fake) + return fake + + +def _tree(tmp_path, requirements=_REQUIREMENTS): + (tmp_path / "requirements.txt").write_text(requirements) + return tmp_path / "sbom.json" + + +def _components(path): + return {c["name"]: c for c in json.loads(path.read_text())["components"]} + + +def _flat(result): + return " ".join(result.output.split()) # Rich wraps long lines + + +# -------------------------------------------------------------------------- +# Default-on resolution +# -------------------------------------------------------------------------- + +def test_scan_writes_pypi_licenses_into_the_sbom(tmp_path, monkeypatch): + out = _tree(tmp_path) + fake = _fake_pypi(monkeypatch) + + result = runner.invoke(app, ["scan", str(tmp_path), "--output", str(out)]) + assert result.exit_code == 0, result.output + + comps = _components(out) + assert comps["transformers"]["licenses"] == [{"license": {"id": "Apache-2.0"}}] + assert comps["torch"]["licenses"] == [{"expression": _torch_info()["license_expression"]}] + assert comps["torch"]["properties"] == [{"name": "aisbom:license:source", "value": "pypi"}] + # Unpinned: never looked up, never licensed. + assert "licenses" not in comps["numpy"] + assert sorted(fake.gets) == [ + "https://pypi.org/pypi/torch/2.13.0/json", + "https://pypi.org/pypi/transformers/5.13.1/json", + ] + flat = _flat(result) + assert "PyPI: resolved licenses for 2 of 2 pinned dependencies" in flat + assert "1 unpinned dependency skipped" in flat + + +def test_a_model_components_legal_status_is_untouched(tmp_path, monkeypatch): + out = _osv_tree(tmp_path, requirements=_REQUIREMENTS) + _fake_pypi(monkeypatch) + runner.invoke(app, ["scan", str(tmp_path), "--output", str(out)]) + for comp in json.loads(out.read_text())["components"]: + if comp["type"] == "machine-learning-model": + assert "pypi" not in json.dumps(comp) + + +def test_pypi_outage_keeps_the_scan_and_its_exit_code(tmp_path, monkeypatch): + out = _osv_tree(tmp_path, requirements=_REQUIREMENTS) + _fake_pypi(monkeypatch, fail=real_requests.ConnectionError("down")) + + result = runner.invoke(app, ["scan", str(tmp_path), "--output", str(out)]) + assert result.exit_code == 2, result.output # the malicious pickle still decides + assert "PyPI license lookup failed for 2 of 2" in _flat(result) + assert all("licenses" not in c for n, c in _components(out).items() + if n in ("torch", "transformers")) + + +def test_spdx_23_output_declares_the_resolved_license(tmp_path, monkeypatch): + out = tmp_path / "sbom.spdx.json" + _tree(tmp_path) + _fake_pypi(monkeypatch) + result = runner.invoke( + app, ["scan", str(tmp_path), "--format", "spdx", "--output", str(out)] + ) + assert result.exit_code == 0, result.output + pkgs = {p["name"]: p for p in json.loads(out.read_text())["packages"]} + assert pkgs["transformers"]["licenseDeclared"] == "Apache-2.0" + assert pkgs["numpy"]["licenseDeclared"] == "NOASSERTION" + + +@pytest.mark.parametrize("args", [ + ["--format", "markdown"], + ["--format", "spdx", "--spdx-version", "3.0"], +]) +def test_outputs_that_carry_no_dependency_license_skip_the_lookup(tmp_path, monkeypatch, args): + _tree(tmp_path) + fake = _fake_pypi(monkeypatch) + result = runner.invoke( + app, ["scan", str(tmp_path), "--output", str(tmp_path / "out"), *args] + ) + assert result.exit_code == 0, result.output + assert fake.calls == 0 + + +def test_a_scan_with_no_pins_makes_no_request_and_prints_nothing(tmp_path, monkeypatch): + out = _tree(tmp_path, requirements="numpy\n") + fake = _fake_pypi(monkeypatch) + result = runner.invoke(app, ["scan", str(tmp_path), "--output", str(out)]) + assert fake.calls == 0 + assert "PyPI" not in result.output + + +def test_score_grades_the_same_licensed_document(tmp_path, monkeypatch): + _tree(tmp_path, requirements="torch==2.13.0\ntransformers==5.13.1\n") + _fake_pypi(monkeypatch) + result = runner.invoke(app, ["score", str(tmp_path), "--json"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) # the PyPI summary stays off stdout + [lic] = [d for d in payload["dimensions"] if d["key"] == "licenses"] + assert lic["score"] == 100 + + +def test_score_offline_leaves_dependency_licenses_unresolved(tmp_path, monkeypatch): + _tree(tmp_path, requirements="torch==2.13.0\n") + fake = _fake_pypi(monkeypatch) + result = runner.invoke(app, ["score", str(tmp_path), "--json", "--offline"]) + assert result.exit_code == 0, result.output + assert fake.calls == 0 + [lic] = [d for d in json.loads(result.stdout)["dimensions"] if d["key"] == "licenses"] + assert lic["score"] == 0 + + +# -------------------------------------------------------------------------- +# --offline / AISBOM_OFFLINE +# -------------------------------------------------------------------------- + +def _offline_args(tmp_path, out): + return ["scan", str(tmp_path), "--output", str(out), "--vex"] + + +def test_offline_flag_skips_pypi_and_osv(tmp_path, monkeypatch): + from tests.test_cli_integration import _fake_osv + + out = _osv_tree(tmp_path, requirements=_REQUIREMENTS) + pypi_fake = _fake_pypi(monkeypatch) + osv_fake = _fake_osv(monkeypatch) + + result = runner.invoke(app, [*_offline_args(tmp_path, out), "--offline"]) + assert result.exit_code == 2, result.output + assert pypi_fake.calls == 0 and osv_fake.calls == 0 + assert "PyPI" not in result.output and "OSV:" not in result.output + + +def test_offline_env_var_is_the_same_as_the_flag(tmp_path, monkeypatch): + out = _tree(tmp_path) + fake = _fake_pypi(monkeypatch) + monkeypatch.setenv("AISBOM_OFFLINE", "1") + result = runner.invoke(app, ["scan", str(tmp_path), "--output", str(out)]) + assert result.exit_code == 0, result.output + assert fake.calls == 0 + + +def test_offline_does_not_leak_into_the_next_invocation(tmp_path, monkeypatch): + out = _tree(tmp_path) + fake = _fake_pypi(monkeypatch) + runner.invoke(app, ["scan", str(tmp_path), "--output", str(out), "--offline"]) + runner.invoke(app, ["scan", str(tmp_path), "--output", str(out)]) + assert fake.calls == 2 + + +@pytest.mark.parametrize("command", ["scan", "score"]) +@pytest.mark.parametrize("target", [ + "hf://google-bert/bert-base-uncased", "https://example.com/model.pt", +]) +def test_offline_refuses_a_remote_target_before_scanning(monkeypatch, command, target): + def explode(*a, **kw): + raise AssertionError("the scanner must not run") + + monkeypatch.setattr("aisbom.cli.DeepScanner", explode) + result = runner.invoke(app, [command, target, "--offline"]) + assert result.exit_code == 1 + assert "--offline" in _flat(result) and "cannot" in _flat(result).lower() + + +def test_offline_refuses_share(tmp_path): + result = runner.invoke( + app, ["scan", str(tmp_path), "--offline", "--share", "--share-yes"] + ) + assert result.exit_code == 1 + assert "--share" in _flat(result) + + +def test_offline_disables_telemetry_and_the_version_check(monkeypatch): + posts, gets = [], [] + monkeypatch.setattr(telemetry.requests, "post", lambda *a, **kw: posts.append(a)) + monkeypatch.setattr(version_check.requests, "get", lambda *a, **kw: gets.append(a)) + + offline.enable(True) + assert _REAL_POST_EVENT("cli_scan", {}) is None + assert _REAL_CHECK_LATEST() is None + assert posts == [] and gets == [] + + +def test_offline_env_disables_telemetry_on_every_command(monkeypatch): + monkeypatch.setenv("AISBOM_OFFLINE", "1") + assert telemetry._telemetry_disabled() + + +def test_info_reports_offline_mode(monkeypatch): + monkeypatch.setenv("AISBOM_OFFLINE", "1") + result = runner.invoke(app, ["info"]) + assert "AISBOM_OFFLINE" in result.output + + +# -------------------------------------------------------------------------- +# Verified, not assumed: the real clients with the network cut +# -------------------------------------------------------------------------- + +def _cut_the_network(monkeypatch): + attempts = [] + + def refuse(*args, **kwargs): + attempts.append(args) + raise OSError("network is unreachable (air-gapped test)") + + monkeypatch.setattr(socket, "getaddrinfo", refuse) + monkeypatch.setattr(socket.socket, "connect", refuse) + return attempts + + +def _restore_real_clients(monkeypatch, tmp_path): + from aisbom import cli, osv, pypi + + monkeypatch.setattr(pypi, "_default_session", lambda: real_requests) + monkeypatch.setattr(osv, "_default_session", lambda: real_requests) + monkeypatch.setattr(telemetry, "post_event", _REAL_POST_EVENT) + monkeypatch.setattr(cli, "check_latest_version", _REAL_CHECK_LATEST) + monkeypatch.setattr(cli, "run_version_check_wrapper", + lambda: cli.update_result.update(version=_REAL_CHECK_LATEST())) + # Real telemetry would create ~/.aisbom/config.json; keep it in tmp. + monkeypatch.setattr(telemetry, "get_config_dir", lambda: tmp_path / "cfg") + monkeypatch.delenv("AISBOM_NO_TELEMETRY", raising=False) + + +def test_offline_scan_makes_no_network_attempt_at_all(tmp_path, monkeypatch): + out = _osv_tree(tmp_path, requirements=_REQUIREMENTS) + _restore_real_clients(monkeypatch, tmp_path) + attempts = _cut_the_network(monkeypatch) + + result = runner.invoke(app, [*_offline_args(tmp_path, out), "--offline"]) + assert result.exit_code == 2, result.output + assert attempts == [], f"--offline tried the network: {attempts}" + assert not (tmp_path / "cfg" / "config.json").exists() + + +def test_air_gapped_scan_without_offline_matches_an_offline_scan(tmp_path, monkeypatch): + offline_dir, gapped_dir = tmp_path / "offline", tmp_path / "gapped" + offline_dir.mkdir() + gapped_dir.mkdir() + offline_out = _osv_tree(offline_dir, requirements=_REQUIREMENTS) + gapped_out = _osv_tree(gapped_dir, requirements=_REQUIREMENTS) + + baseline = runner.invoke(app, ["scan", str(offline_dir), "--output", + str(offline_out), "--offline"]) + + monkeypatch.setattr("aisbom.pypi._default_session", lambda: real_requests) + attempts = _cut_the_network(monkeypatch) + gapped = runner.invoke(app, ["scan", str(gapped_dir), "--output", str(gapped_out)]) + + assert attempts, "the lookup never tried the network, so nothing was proven" + assert gapped.exit_code == baseline.exit_code == 2, gapped.output + + def shape(path): + return sorted( + (c["name"], c["type"], json.dumps(c.get("properties"), sort_keys=True), + json.dumps(c.get("licenses"), sort_keys=True)) + for c in json.loads(path.read_text())["components"] + ) + + assert shape(gapped_out) == shape(offline_out) diff --git a/tests/test_spdx.py b/tests/test_spdx.py index 3fe45c3..9ffd57e 100644 --- a/tests/test_spdx.py +++ b/tests/test_spdx.py @@ -272,6 +272,45 @@ def test_dependency_spdxids_unique_for_repeated_pins(): assert len(set(ids)) == 2 +def _lib_package(data, name): + return next(p for p in data["packages"] if p["name"] == name) + + +def test_a_pypi_resolved_spdx_license_is_declared(): + """#129: the license PyPI declares is the package's *declared* license. The + concluded license stays NOASSERTION — nobody reviewed it.""" + data = _generate(dependencies=[{ + "name": "torch", "version": "2.13.0", "license": "BSD-3-Clause AND MIT", + "license_is_spdx": True, "license_source": "pypi", + }]) + pkg = _lib_package(data, "torch") + assert pkg["licenseDeclared"] == "BSD-3-Clause AND MIT" + assert pkg["licenseConcluded"] == "NOASSERTION" + + +def test_a_non_spdx_license_name_is_not_declared(): + """SPDX 2.3 can only carry SPDX expressions; inventing a LicenseRef for a + free-text name would put words in the package author's mouth.""" + data = _generate(dependencies=[{ + "name": "vendorlib", "version": "1.0", "license": "Proprietary", + "license_is_spdx": False, "license_source": "pypi", + }]) + assert _lib_package(data, "vendorlib")["licenseDeclared"] == "NOASSERTION" + + +def test_a_value_wrongly_flagged_spdx_costs_the_field_not_the_document(): + data = _generate(dependencies=[{ + "name": "oddlib", "version": "1.0", "license": "MIT OR", + "license_is_spdx": True, "license_source": "pypi", + }]) + assert _lib_package(data, "oddlib")["licenseDeclared"] == "NOASSERTION" + + +def test_an_unresolved_dependency_still_declares_noassertion(): + data = _generate(dependencies=[{"name": "requests", "version": "2.28.1"}]) + assert _lib_package(data, "requests")["licenseDeclared"] == "NOASSERTION" + + # --- Whole-document validity ---------------------------------------------- def test_empty_scan_emits_a_valid_document(): @@ -301,6 +340,11 @@ def test_document_validates_as_spdx_2_3(): [ {"name": "requests", "version": "2.28.1"}, {"name": "torch", "version": "2.0.*"}, + {"name": "torch", "version": "2.13.0", "license_is_spdx": True, + "license": "Apache-2.0 AND Apache-2.0 WITH LLVM-exception AND MIT", + "license_source": "pypi"}, + {"name": "vendorlib", "version": "1.0", "license": "Proprietary", + "license_is_spdx": False, "license_source": "pypi"}, ], ) document = JsonLikeDictParser().parse(data) From 3d5b314fceeacdfa855583ceb7c4b75c2f59d9b6 Mon Sep 17 00:00:00 2001 From: Ajoy L Date: Mon, 14 Sep 2026 22:05:48 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20count?= =?UTF-8?q?=20every=20license=20classifier;=20refuse=20offline+share=20in?= =?UTF-8?q?=20the=20Action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classifiers: a mapped classifier beside one the table cannot map (MIT plus the generic BSD License) used to resolve to plain MIT, because unmapped entries were dropped before counting. Every License :: classifier now counts, bar the bare OSI Approved / DFSG approved category nodes, and more than one resolves nothing. Action: with AISBOM_OFFLINE=1 in the step env and share: true, the CLI refuses with exit 1, which the entrypoint does not re-raise, so the job passed and a sbom.json already in the workspace would have been commented on and uploaded. The entrypoint now rejects that pair before scanning and exits 1. --- action/entrypoint.sh | 10 ++++++++++ aisbom/pypi.py | 25 +++++++++++++++++++++---- tests/test_action_entrypoint.py | 29 +++++++++++++++++++++++++++++ tests/test_pypi.py | 21 +++++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/action/entrypoint.sh b/action/entrypoint.sh index f63068d..2ae936a 100755 --- a/action/entrypoint.sh +++ b/action/entrypoint.sh @@ -22,6 +22,7 @@ # # Exit codes: # 0 — Scan succeeded OR scan reported risks but fail-on-risk is false. +# 1 — share: true was combined with AISBOM_OFFLINE (refused before scanning). # 2 — Scan reported CRITICAL findings AND fail-on-risk is true. # 3 — Platform upload failed AND fail-on-platform-error is true. # @@ -72,6 +73,15 @@ if [ -n "${INPUT_TOKEN}" ]; then VEX_ARGS=(--vex) fi +# `AISBOM_OFFLINE=1` in the step env forbids network access and `share: true` +# requires it, so the CLI refuses the pair with exit 1. Step 4 only re-raises +# exit 2, so without this the job would pass — and an `sbom.json` already in +# the workspace would be commented on and uploaded as if freshly scanned. +if [ -n "${AISBOM_OFFLINE:-}" ] && [ "${INPUT_SHARE}" = "true" ]; then + echo "[aisbom-action] share: true cannot be combined with AISBOM_OFFLINE: sharing uploads the SBOM to aisbom.io. Remove one of them." + exit 1 +fi + SHARE_ARGS=() if [ "${INPUT_SHARE}" = "true" ]; then SHARE_ARGS=(--share --share-yes) diff --git a/aisbom/pypi.py b/aisbom/pypi.py index c9a0e91..812b9ab 100644 --- a/aisbom/pypi.py +++ b/aisbom/pypi.py @@ -165,6 +165,14 @@ class LicenseLookupResult: "License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication": "CC0-1.0", } +# Parent nodes of the trove hierarchy. Packages often list one beside the leaf +# (`License :: OSI Approved` and `License :: OSI Approved :: MIT License`); it +# names no license, so it is not a second declaration. +_CATEGORY_CLASSIFIERS = frozenset({ + "License :: OSI Approved", + "License :: DFSG approved", +}) + _PLACEHOLDERS = frozenset({ "unknown", "none", "null", "n/a", "na", "other", "license", "licence", }) @@ -231,11 +239,20 @@ def normalize_license(info: Any) -> Optional[ResolvedLicense]: classifiers = info.get("classifiers") if isinstance(classifiers, list): - mapped = {_CLASSIFIERS[c] for c in classifiers if c in _CLASSIFIERS} + # Every license classifier counts, including ones this table cannot + # map: MIT beside a generic "BSD License" may be dual licensing, and + # dropping the unmapped one before counting would report plain MIT. # Two license classifiers could mean a choice, a combination or a stale - # leftover. Joining them into an expression would assert one of those. - if len(mapped) == 1: - return ResolvedLicense(mapped.pop(), is_spdx=True) + # leftover, so any more than one resolves nothing. + declared = { + c for c in classifiers + if isinstance(c, str) and c.startswith("License ::") + and c not in _CATEGORY_CLASSIFIERS + } + if len(declared) == 1: + (only,) = declared + if only in _CLASSIFIERS: + return ResolvedLicense(_CLASSIFIERS[only], is_spdx=True) if name: return ResolvedLicense(name, is_spdx=False) diff --git a/tests/test_action_entrypoint.py b/tests/test_action_entrypoint.py index f990c94..cca8971 100644 --- a/tests/test_action_entrypoint.py +++ b/tests/test_action_entrypoint.py @@ -108,6 +108,7 @@ def run_entrypoint( create_sbom: bool = False, scan_exit: int = 0, python_exit: int = 0, + extra_env: dict[str, str] | None = None, ) -> EntrypointRun: """Execute entrypoint.sh with stubbed `aisbom` and `python` on PATH.""" bindir = tmp_path / "bin" @@ -137,6 +138,7 @@ def run_entrypoint( "AISBOM_SCAN_LOG": str(scan_log), "AISBOM_EXIT": str(scan_exit), "PYTHON_EXIT": str(python_exit), + **(extra_env or {}), } proc = subprocess.run( @@ -379,3 +381,30 @@ def test_clean_scan_without_token_is_unaffected(self, tmp_path): run = run_entrypoint(tmp_path, [*BASE_ARGS, "false"], create_sbom=True) assert run.proc.returncode == 0 assert not run.ran_script("platform_upload.py") + + +class TestOfflineMode: + """`AISBOM_OFFLINE=1` in the step env forbids the network; `share: true` + requires it. The CLI refuses that pair with exit 1, which this wrapper does + not propagate — so the job would pass, and a `sbom.json` already in the + workspace would be commented on and uploaded as if freshly scanned. The + contradiction is caught here, before the scan runs.""" + + OFFLINE = {"AISBOM_OFFLINE": "1"} + + def test_offline_with_share_fails_before_scanning(self, tmp_path): + run = run_entrypoint( + tmp_path, [*TOKEN_ARGS, "true"], create_sbom=True, extra_env=self.OFFLINE + ) + assert run.proc.returncode == 1 + assert run.scan_argv == [], "the scan must not run" + assert run.python_invocations == [], "a stale SBOM must not be commented or uploaded" + assert "AISBOM_OFFLINE" in run.proc.stdout and "share" in run.proc.stdout + + def test_offline_without_share_scans_normally(self, tmp_path): + run = run_entrypoint( + tmp_path, [*BASE_ARGS, "false"], create_sbom=True, extra_env=self.OFFLINE + ) + assert run.proc.returncode == 0 + assert run.scan_argv[0] == "scan" + assert run.ran_script("post_comment.py") diff --git a/tests/test_pypi.py b/tests/test_pypi.py index 9c7c642..85d1c1a 100644 --- a/tests/test_pypi.py +++ b/tests/test_pypi.py @@ -186,6 +186,27 @@ def test_ambiguous_classifiers_resolve_nothing(classifier): assert pypi.normalize_license(info) is None +@pytest.mark.parametrize("other", [ + "License :: OSI Approved :: BSD License", + "License :: Other/Proprietary License", +]) +def test_a_mapped_classifier_beside_an_unmapped_one_resolves_nothing(other): + # MIT alongside a generic BSD classifier may mean dual licensing; dropping + # the one we cannot map and reporting "MIT" would assert a single license. + info = {"license": None, "classifiers": [ + "License :: OSI Approved :: MIT License", other, + ]} + assert pypi.normalize_license(info) is None + + +def test_the_bare_osi_approved_category_is_not_a_second_license(): + info = {"license": None, "classifiers": [ + "License :: OSI Approved", + "License :: OSI Approved :: MIT License", + ]} + assert pypi.normalize_license(info) == pypi.ResolvedLicense("MIT", is_spdx=True) + + def test_two_different_classifiers_are_not_guessed_into_an_expression(): info = {"license": None, "classifiers": [ "License :: OSI Approved :: MIT License",